diff --git a/components/FormDialog.tsx b/components/FormDialog.tsx index dcd7670..de13dff 100644 --- a/components/FormDialog.tsx +++ b/components/FormDialog.tsx @@ -8,6 +8,7 @@ import DialogContentText from '@material-ui/core/DialogContentText'; import DialogTitle from '@material-ui/core/DialogTitle'; import useUser from 'store/modules/user/useUser'; +import useWatchlist from 'store/modules/watchlist/useWatchlist'; import { LOGIN_API, REGISTER_API } from 'utils/config'; export default function FormDialog({ @@ -24,6 +25,7 @@ export default function FormDialog({ const [user, setUser] = useState(''); const { login } = useUser(); + const { fetchWatchlist } = useWatchlist(); const handleSubmit = () => { if (!signIn) @@ -45,6 +47,7 @@ export default function FormDialog({ alert(res.error.message); } else { login(res.user); + fetchWatchlist(); } }) .finally(() => setOpen(false)); @@ -66,6 +69,7 @@ export default function FormDialog({ alert(res.error.message); } else { login(res.user); + fetchWatchlist(); } }) .finally(() => { diff --git a/components/StockList/StockListItem.tsx b/components/StockList/StockListItem.tsx new file mode 100644 index 0000000..0c97bdf --- /dev/null +++ b/components/StockList/StockListItem.tsx @@ -0,0 +1,47 @@ +import clsx from 'clsx'; +import { faTimes } from '@fortawesome/free-solid-svg-icons'; + +import styles from 'components/StockList/StockList.module.scss'; + +import IconButton from 'components/IconButton'; +import { IWatchlistItem } from 'components/StockList'; + +interface Props { + item: IWatchlistItem; + deleteItem: (id: number) => void; +} + +const StockListItem = ({ item, deleteItem }: Props): JSX.Element => { + return ( +
  • +
    + +
    +

    + {item.symbol} +

    +

    UC

    +
    +
    + + deleteItem(item.id)} + icon={faTimes} + color="red" + bgc="rgb(249,249,250)" + /> + +
    +
  • + ); +}; + +export default StockListItem; diff --git a/components/StockList/index.tsx b/components/StockList/index.tsx index 90ca5fc..636f521 100644 --- a/components/StockList/index.tsx +++ b/components/StockList/index.tsx @@ -1,19 +1,14 @@ -import { useCallback, useEffect, useState } from 'react'; - -import clsx from 'clsx'; -import { faTimes } from '@fortawesome/free-solid-svg-icons'; +import { useEffect } from 'react'; import styles from 'components/StockList/StockList.module.scss'; -import IconButton from 'components/IconButton'; +import StockListItem from 'components/StockList/StockListItem'; import MarketInfoListItem from 'components/MarketInfoList/MarketInfoListItem'; import Spinner from 'components/Spinner'; -import { WATCHLISTS_API } from 'utils/config'; - -type WatchListData = WatchlistItem[]; +import useWatchlist from 'store/modules/watchlist/useWatchlist'; -export interface WatchlistItem { +export interface IWatchlistItem { c: number; d: number; dp: number; @@ -22,81 +17,21 @@ export interface WatchlistItem { } const StockList = ({ editMode }: { editMode?: boolean }): JSX.Element => { - const [wData, setWData] = useState([]); - const [wLoading, setWLoading] = useState(false); - - const fetchWatchlist = useCallback(async () => { - try { - setWLoading(true); - const res = await fetch(WATCHLISTS_API, { - headers: { - Authorization: `Bearer ${localStorage.getItem('token')}`, - }, - }); - const resData = await res.json(); - console.log(resData); - setWData(resData); - setWLoading(false); - } catch (e) { - setWLoading(false); - console.log('fetch watchlist error'); - } - }, []); - - const deleteWatchlist = useCallback(async (id) => { - try { - await fetch(`${WATCHLISTS_API}/${id}`, { - method: 'DELETE', - headers: { - Authorization: `Bearer ${localStorage.getItem('token')}`, - }, - }); - window.location.reload(); - } catch (e) { - console.log('delete watchlist error'); - } - }, []); + const { watchlistData, watchlistStatus, fetchWatchlist, deleteWatchlist } = + useWatchlist(); useEffect(() => { fetchWatchlist(); - return () => setWLoading(false); }, [fetchWatchlist]); return ( <> - {wLoading && } - {wData.length ? ( + {watchlistStatus === 'loading' && } + {watchlistData.length ? (
      - {wData.map((d) => + {watchlistData.map((d: IWatchlistItem) => editMode ? ( -
    • -
      - -
      -

      - {d.symbol} -

      -

      UC

      -
      -
      - - deleteWatchlist(d.id)} - icon={faTimes} - color="red" - bgc="rgb(249,249,250)" - /> - -
      -
    • + ) : ( '], }; export default createJestConfig(customJestConfig); diff --git a/package-lock.json b/package-lock.json index fe773a1..32882bd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -34,6 +34,7 @@ "react-google-login": "^5.2.2", "react-redux": "^7.2.6", "react-toggle": "^4.1.2", + "redux-logger": "^3.0.6", "redux-persist": "^6.0.0", "sass": "^1.49.0" }, @@ -43,10 +44,12 @@ "@babel/preset-typescript": "^7.16.7", "@testing-library/jest-dom": "^5.16.2", "@testing-library/react": "12.1.2", + "@testing-library/react-hooks": "^7.0.2", "@testing-library/user-event": "13.5.0", "@types/react": "17.0.38", "@types/react-redux": "^7.1.22", "@types/react-toggle": "^4.0.3", + "@types/redux-logger": "^3.0.9", "@typescript-eslint/eslint-plugin": "^5.10.0", "@typescript-eslint/parser": "^5.10.0", "babel-jest": "27.4.5", @@ -58,6 +61,7 @@ "identity-obj-proxy": "^3.0.0", "jest": "27.4.5", "msw": "^0.38.1", + "next-router-mock": "^0.6.5", "ts-node": "^10.5.0", "typescript": "^4.5.5" } @@ -3255,6 +3259,35 @@ "react-dom": "*" } }, + "node_modules/@testing-library/react-hooks": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@testing-library/react-hooks/-/react-hooks-7.0.2.tgz", + "integrity": "sha512-dYxpz8u9m4q1TuzfcUApqi8iFfR6R0FaMbr2hjZJy1uC8z+bO/K4v8Gs9eogGKYQop7QsrBTFkv/BCF7MzD2Cg==", + "dev": true, + "dependencies": { + "@babel/runtime": "^7.12.5", + "@types/react": ">=16.9.0", + "@types/react-dom": ">=16.9.0", + "@types/react-test-renderer": ">=16.9.0", + "react-error-boundary": "^3.1.0" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0", + "react-test-renderer": ">=16.9.0" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + }, + "react-test-renderer": { + "optional": true + } + } + }, "node_modules/@testing-library/user-event": { "version": "13.5.0", "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-13.5.0.tgz", @@ -3461,6 +3494,15 @@ "csstype": "^3.0.2" } }, + "node_modules/@types/react-dom": { + "version": "17.0.12", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-17.0.12.tgz", + "integrity": "sha512-SeJ430ndLI15JtRSHuzotn7AIdUtr8bdk6XW8mMfzjZo3vahRgJGHZqHiI4nAzCHTVG4qC21ObfsHBVUEHcDhg==", + "dev": true, + "dependencies": { + "@types/react": "*" + } + }, "node_modules/@types/react-redux": { "version": "7.1.22", "resolved": "https://registry.npmjs.org/@types/react-redux/-/react-redux-7.1.22.tgz", @@ -3472,6 +3514,15 @@ "redux": "^4.0.0" } }, + "node_modules/@types/react-test-renderer": { + "version": "17.0.1", + "resolved": "https://registry.npmjs.org/@types/react-test-renderer/-/react-test-renderer-17.0.1.tgz", + "integrity": "sha512-3Fi2O6Zzq/f3QR9dRnlnHso9bMl7weKCviFmfF6B4LS1Uat6Hkm15k0ZAQuDz+UBq6B3+g+NM6IT2nr5QgPzCw==", + "dev": true, + "dependencies": { + "@types/react": "*" + } + }, "node_modules/@types/react-toggle": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/@types/react-toggle/-/react-toggle-4.0.3.tgz", @@ -3489,6 +3540,15 @@ "@types/react": "*" } }, + "node_modules/@types/redux-logger": { + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/@types/redux-logger/-/redux-logger-3.0.9.tgz", + "integrity": "sha512-cwYhVbYNgH01aepeMwhd0ABX6fhVB2rcQ9m80u8Fl50ZODhsZ8RhQArnLTkE7/Zrfq4Sz/taNoF7DQy9pCZSKg==", + "dev": true, + "dependencies": { + "redux": "^4.0.0" + } + }, "node_modules/@types/scheduler": { "version": "0.16.2", "resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.16.2.tgz", @@ -4996,6 +5056,11 @@ "integrity": "sha1-JJXduvbrh0q7Dhvp3yLS5aVEMmw=", "dev": true }, + "node_modules/deep-diff": { + "version": "0.3.8", + "resolved": "https://registry.npmjs.org/deep-diff/-/deep-diff-0.3.8.tgz", + "integrity": "sha1-wB3mPvsO7JeYgB1Ax+Da4ltYLIQ=" + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -10139,6 +10204,16 @@ "react-redux": "*" } }, + "node_modules/next-router-mock": { + "version": "0.6.5", + "resolved": "https://registry.npmjs.org/next-router-mock/-/next-router-mock-0.6.5.tgz", + "integrity": "sha512-gh6phWv4YUhFON0rWGmc02ni91m68ICG1HTj2N9bi2Y0MIlp5Z12QITXF4lNtV33wuMeUzrs/Ik6XyNOZ8rmNQ==", + "dev": true, + "peerDependencies": { + "next": ">=10.0.0", + "react": ">=17.0.0" + } + }, "node_modules/next-themes": { "version": "0.0.15", "resolved": "https://registry.npmjs.org/next-themes/-/next-themes-0.0.15.tgz", @@ -11001,6 +11076,22 @@ "react": "17.0.2" } }, + "node_modules/react-error-boundary": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/react-error-boundary/-/react-error-boundary-3.1.4.tgz", + "integrity": "sha512-uM9uPzZJTF6wRQORmSrvOIgt4lJ9MC1sNgEOj2XGsDTRE4kmpWxg7ENK9EWNKJRMAOY9z0MuF4yIfl6gp4sotA==", + "dev": true, + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=10", + "npm": ">=6" + }, + "peerDependencies": { + "react": ">=16.13.1" + } + }, "node_modules/react-google-login": { "version": "5.2.2", "resolved": "https://registry.npmjs.org/react-google-login/-/react-google-login-5.2.2.tgz", @@ -11116,6 +11207,14 @@ "@babel/runtime": "^7.9.2" } }, + "node_modules/redux-logger": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/redux-logger/-/redux-logger-3.0.6.tgz", + "integrity": "sha1-91VZZvMJjzyIYExEnPC69XeCdL8=", + "dependencies": { + "deep-diff": "^0.3.5" + } + }, "node_modules/redux-persist": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/redux-persist/-/redux-persist-6.0.0.tgz", @@ -14968,6 +15067,19 @@ "@testing-library/dom": "^8.0.0" } }, + "@testing-library/react-hooks": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@testing-library/react-hooks/-/react-hooks-7.0.2.tgz", + "integrity": "sha512-dYxpz8u9m4q1TuzfcUApqi8iFfR6R0FaMbr2hjZJy1uC8z+bO/K4v8Gs9eogGKYQop7QsrBTFkv/BCF7MzD2Cg==", + "dev": true, + "requires": { + "@babel/runtime": "^7.12.5", + "@types/react": ">=16.9.0", + "@types/react-dom": ">=16.9.0", + "@types/react-test-renderer": ">=16.9.0", + "react-error-boundary": "^3.1.0" + } + }, "@testing-library/user-event": { "version": "13.5.0", "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-13.5.0.tgz", @@ -15164,6 +15276,15 @@ "csstype": "^3.0.2" } }, + "@types/react-dom": { + "version": "17.0.12", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-17.0.12.tgz", + "integrity": "sha512-SeJ430ndLI15JtRSHuzotn7AIdUtr8bdk6XW8mMfzjZo3vahRgJGHZqHiI4nAzCHTVG4qC21ObfsHBVUEHcDhg==", + "dev": true, + "requires": { + "@types/react": "*" + } + }, "@types/react-redux": { "version": "7.1.22", "resolved": "https://registry.npmjs.org/@types/react-redux/-/react-redux-7.1.22.tgz", @@ -15175,6 +15296,15 @@ "redux": "^4.0.0" } }, + "@types/react-test-renderer": { + "version": "17.0.1", + "resolved": "https://registry.npmjs.org/@types/react-test-renderer/-/react-test-renderer-17.0.1.tgz", + "integrity": "sha512-3Fi2O6Zzq/f3QR9dRnlnHso9bMl7weKCviFmfF6B4LS1Uat6Hkm15k0ZAQuDz+UBq6B3+g+NM6IT2nr5QgPzCw==", + "dev": true, + "requires": { + "@types/react": "*" + } + }, "@types/react-toggle": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/@types/react-toggle/-/react-toggle-4.0.3.tgz", @@ -15192,6 +15322,15 @@ "@types/react": "*" } }, + "@types/redux-logger": { + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/@types/redux-logger/-/redux-logger-3.0.9.tgz", + "integrity": "sha512-cwYhVbYNgH01aepeMwhd0ABX6fhVB2rcQ9m80u8Fl50ZODhsZ8RhQArnLTkE7/Zrfq4Sz/taNoF7DQy9pCZSKg==", + "dev": true, + "requires": { + "redux": "^4.0.0" + } + }, "@types/scheduler": { "version": "0.16.2", "resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.16.2.tgz", @@ -16307,6 +16446,11 @@ "integrity": "sha1-JJXduvbrh0q7Dhvp3yLS5aVEMmw=", "dev": true }, + "deep-diff": { + "version": "0.3.8", + "resolved": "https://registry.npmjs.org/deep-diff/-/deep-diff-0.3.8.tgz", + "integrity": "sha1-wB3mPvsO7JeYgB1Ax+Da4ltYLIQ=" + }, "deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -20109,6 +20253,13 @@ "integrity": "sha512-UFXdAWG5i+GFT8+Hoqpx3GArkPh34fVWF9YoA2VSHlBzsrPtnRd7NWM6FNSYUennpommTpWJ09mu+r/1UxyIkg==", "requires": {} }, + "next-router-mock": { + "version": "0.6.5", + "resolved": "https://registry.npmjs.org/next-router-mock/-/next-router-mock-0.6.5.tgz", + "integrity": "sha512-gh6phWv4YUhFON0rWGmc02ni91m68ICG1HTj2N9bi2Y0MIlp5Z12QITXF4lNtV33wuMeUzrs/Ik6XyNOZ8rmNQ==", + "dev": true, + "requires": {} + }, "next-themes": { "version": "0.0.15", "resolved": "https://registry.npmjs.org/next-themes/-/next-themes-0.0.15.tgz", @@ -20728,6 +20879,15 @@ "scheduler": "^0.20.2" } }, + "react-error-boundary": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/react-error-boundary/-/react-error-boundary-3.1.4.tgz", + "integrity": "sha512-uM9uPzZJTF6wRQORmSrvOIgt4lJ9MC1sNgEOj2XGsDTRE4kmpWxg7ENK9EWNKJRMAOY9z0MuF4yIfl6gp4sotA==", + "dev": true, + "requires": { + "@babel/runtime": "^7.12.5" + } + }, "react-google-login": { "version": "5.2.2", "resolved": "https://registry.npmjs.org/react-google-login/-/react-google-login-5.2.2.tgz", @@ -20810,6 +20970,14 @@ "@babel/runtime": "^7.9.2" } }, + "redux-logger": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/redux-logger/-/redux-logger-3.0.6.tgz", + "integrity": "sha1-91VZZvMJjzyIYExEnPC69XeCdL8=", + "requires": { + "deep-diff": "^0.3.5" + } + }, "redux-persist": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/redux-persist/-/redux-persist-6.0.0.tgz", diff --git a/package.json b/package.json index 60f3f3b..d0b85f9 100644 --- a/package.json +++ b/package.json @@ -36,6 +36,7 @@ "react-google-login": "^5.2.2", "react-redux": "^7.2.6", "react-toggle": "^4.1.2", + "redux-logger": "^3.0.6", "redux-persist": "^6.0.0", "sass": "^1.49.0" }, @@ -50,6 +51,7 @@ "@types/react": "17.0.38", "@types/react-redux": "^7.1.22", "@types/react-toggle": "^4.0.3", + "@types/redux-logger": "^3.0.9", "@typescript-eslint/eslint-plugin": "^5.10.0", "@typescript-eslint/parser": "^5.10.0", "babel-jest": "27.4.5", diff --git a/pages/market/[category]/[symbol].tsx b/pages/market/[category]/[symbol].tsx index 2115c2b..a1eb70b 100644 --- a/pages/market/[category]/[symbol].tsx +++ b/pages/market/[category]/[symbol].tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useState } from 'react'; +import { useCallback } from 'react'; import { NextPageContext } from 'next'; import { faSearch, faStar } from '@fortawesome/free-solid-svg-icons'; @@ -11,10 +11,9 @@ import { useRouter } from 'next/router'; import MarketDetail from 'components/MarketDetail'; import Header from 'components/Header'; import IconButton from 'components/IconButton'; -import { WatchlistItem } from 'components/StockList'; import useUser from 'store/modules/user/useUser'; -import { WATCHLISTS_API } from 'utils/config'; +import useWatchlist from 'store/modules/watchlist/useWatchlist'; export interface MarketDetailProps { symbol: string; @@ -26,68 +25,21 @@ export default function MarketDetailPage({ category, }: MarketDetailProps): JSX.Element { const router = useRouter(); - const { isLoggedIn, userData } = useUser(); - const [isWatched, setIsWatched] = useState(0); + const { isLoggedIn } = useUser(); + const { checkWatchlistBySymbol, addWatchlist, deleteWatchlist } = + useWatchlist(); - const addToWatchlist = useCallback( - async (sym: string) => { - if (isLoggedIn && !isWatched) { - try { - const res = await fetch(WATCHLISTS_API, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${localStorage.getItem('token')}`, - }, - body: JSON.stringify({ - data: { symbol: sym, email: userData.email }, - }), - }); - const resData = await res.json(); - setIsWatched(resData.data.id); - } catch (e) { - console.log('add to watchlist error'); - } + const handleAddToWatchlist = useCallback( + (sym: string) => { + if (isLoggedIn && !checkWatchlistBySymbol(sym)) { + addWatchlist(sym); } else { alert('You need to login to add to watchlist'); } }, - [isLoggedIn, isWatched, userData] + [isLoggedIn, checkWatchlistBySymbol, addWatchlist] ); - const deleteWatchlist = async (id: number) => { - try { - await fetch(`${WATCHLISTS_API}/${id}`, { - method: 'DELETE', - headers: { - Authorization: `Bearer ${localStorage.getItem('token')}`, - }, - }); - window.location.reload(); - } catch (e) { - console.log('delete watchlist error'); - } - }; - - const checkExist = async (sym: string) => { - try { - const res = await fetch(WATCHLISTS_API, { - headers: { - Authorization: `Bearer ${localStorage.getItem('token')}`, - }, - }); - const resData = await res.json(); - const item = resData.find((d: WatchlistItem) => d.symbol === sym); - setIsWatched(item?.id || 0); - } catch (e) { - console.log('check exist error'); - } - }; - - useEffect(() => { - checkExist(symbol); - }); - return ( <>
      @@ -95,13 +47,16 @@ export default function MarketDetailPage({ onClick={() => router.push('search/market')} icon={faSearch} /> - {isWatched ? ( + {checkWatchlistBySymbol(symbol) ? ( deleteWatchlist(isWatched)} + onClick={() => deleteWatchlist(checkWatchlistBySymbol(symbol))} icon={faStar} /> ) : ( - addToWatchlist(symbol)} icon={faRegStar} /> + handleAddToWatchlist(symbol)} + icon={faRegStar} + /> )}
      diff --git a/store/index.ts b/store/index.ts index 82da8df..2b97f60 100644 --- a/store/index.ts +++ b/store/index.ts @@ -11,25 +11,24 @@ import { REGISTER, } from 'redux-persist'; import storage from 'redux-persist/lib/storage'; -// import logger from 'redux-logger'; +import logger from 'redux-logger'; const persistConfig = { key: 'root', storage, - timeout: 1000, + timeout: 100, }; export const persistedReducer = persistReducer(persistConfig, rootReducer); export const store = configureStore({ reducer: persistedReducer, - // middleware: (getDefaultMiddleware) => getDefaultMiddleware().concat(logger), middleware: (getDefaultMiddleware) => getDefaultMiddleware({ serializableCheck: { ignoredActions: [FLUSH, REHYDRATE, PAUSE, PERSIST, PURGE, REGISTER], }, - }), + }).concat(logger), devTools: process.env.NODE_ENV !== 'production', }); const makeStore = () => store; diff --git a/store/modules/index.ts b/store/modules/index.ts index bbeb7a3..855d5b0 100644 --- a/store/modules/index.ts +++ b/store/modules/index.ts @@ -2,14 +2,17 @@ import { combineReducers, CombinedState, AnyAction } from '@reduxjs/toolkit'; import { HYDRATE } from 'next-redux-wrapper'; import userReducer, { UserState } from 'store/modules/user'; +import watchlistReducer, { WatchlistState } from './watchlist/watchlistSlice'; interface IRootReducer { userReducer: UserState; + watchlistReducer: WatchlistState; // add more } const appReducer = combineReducers({ userReducer, + watchlistReducer, }); const rootReducer = ( diff --git a/store/modules/user/index.ts b/store/modules/user/index.ts index 2d57bb8..61b977e 100644 --- a/store/modules/user/index.ts +++ b/store/modules/user/index.ts @@ -16,7 +16,6 @@ const initialState: UserState = { userData: null, }; -// reducer 객체를 가지고 있는데 이 객체의 key/ value쌍을 slice 라고 한다 export const userSlice = createSlice({ name: 'user', initialState, diff --git a/store/modules/watchlist/__tests__/reducer.test.ts b/store/modules/watchlist/__tests__/reducer.test.ts new file mode 100644 index 0000000..d15fa63 --- /dev/null +++ b/store/modules/watchlist/__tests__/reducer.test.ts @@ -0,0 +1,65 @@ +import watchlistReducer, { + initialState, + fetchWatchlist, +} from 'store/modules/watchlist/watchlistSlice'; + +describe('watchlistSlice', () => { + describe('reducers', () => { + it('sets status loading when fetchWatchlist is pending', () => { + const action = { type: fetchWatchlist.pending.type }; + const state = watchlistReducer(initialState, action); + + expect(state).toEqual({ watchlist: [], status: 'loading', error: '' }); + }); + + it('sets the watchlist when fetchWatchlist is fulfilled', () => { + const action = { + type: fetchWatchlist.fulfilled.type, + payload: [ + { c: 839.29, d: -40.6, dp: -4.6142, id: 120, symbol: 'TSLA' }, + { + c: 166.23, + d: -0.33, + dp: -0.1981, + id: 121, + symbol: 'AAPL', + }, + ], + }; + const state = watchlistReducer(initialState, action); + + expect(state).toEqual({ + watchlist: [ + { c: 839.29, d: -40.6, dp: -4.6142, id: 120, symbol: 'TSLA' }, + { + c: 166.23, + d: -0.33, + dp: -0.1981, + id: 121, + symbol: 'AAPL', + }, + ], + status: 'succeeded', + error: '', + }); + }); + + it('sets status false when fetchWatchlist is rejected', () => { + const action = { + type: fetchWatchlist.rejected.type, + error: { + message: 'Failed to fetch', + name: 'TypeError', + stack: 'TypeError: Failed to fetch', + }, + }; + const state = watchlistReducer(initialState, action); + + expect(state).toEqual({ + watchlist: [], + status: 'failed', + error: 'Failed to fetch', + }); + }); + }); +}); diff --git a/store/modules/watchlist/useWatchlist.ts b/store/modules/watchlist/useWatchlist.ts new file mode 100644 index 0000000..20d356b --- /dev/null +++ b/store/modules/watchlist/useWatchlist.ts @@ -0,0 +1,72 @@ +import { IWatchlistItem } from 'components/StockList'; +import { useCallback } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; + +import { RootState } from 'store/modules'; +import { + fetchWatchlist as fetchData, + addWatchlist as addData, + deleteWatchlist as deleteData, +} from 'store/modules/watchlist/watchlistSlice'; +import useUser from 'store/modules/user/useUser'; + +export default function useWatchlist() { + const { userData } = useUser(); + const dispatch = useDispatch(); + const watchlistData = useSelector( + (state: RootState) => state.watchlistReducer.watchlist + ); + const watchlistStatus = useSelector( + (state: RootState) => state.watchlistReducer.status + ); + const watchlistError = useSelector( + (state: RootState) => state.watchlistReducer.error + ); + const checkWatchlistBySymbol = useCallback( + (sym: string) => { + const item = watchlistData.find((d: IWatchlistItem) => d.symbol === sym); + return item?.id || 0; + }, + [watchlistData] + ); + const fetchWatchlist = useCallback(() => { + try { + dispatch(fetchData()); + } catch (e) { + console.error('fetch watchlist error'); + } + }, [dispatch]); + const addWatchlist = useCallback( + (symbol: string) => { + try { + const data = { symbol, email: userData?.email }; + dispatch(addData(data)); + } catch (e) { + console.error('add watchlist error'); + } + fetchWatchlist(); + }, + [dispatch, userData?.email, fetchWatchlist] + ); + const deleteWatchlist = useCallback( + async (id: number) => { + try { + await dispatch(deleteData(id)); + } catch (e) { + console.error('delete watchlist error'); + } + fetchWatchlist(); + }, + [dispatch, fetchWatchlist] + ); + + return { + watchlistData, + watchlistStatus, + watchlistError, + checkWatchlistBySymbol, + fetchWatchlist, + addWatchlist, + deleteWatchlist, + }; +} diff --git a/store/modules/watchlist/watchlistSlice.ts b/store/modules/watchlist/watchlistSlice.ts new file mode 100644 index 0000000..1681d02 --- /dev/null +++ b/store/modules/watchlist/watchlistSlice.ts @@ -0,0 +1,92 @@ +import { createAsyncThunk, createSlice } from '@reduxjs/toolkit'; + +import { IWatchlistItem } from 'components/StockList'; +import { WATCHLISTS_API } from 'utils/config'; + +export interface WatchlistState { + watchlist: IWatchlistItem[]; + status: 'idle' | 'loading' | 'succeeded' | 'failed'; + error: string | ''; +} + +const wEnhancers: Array = []; +export const initialState = { + watchlist: wEnhancers, + status: 'idle', + error: '', +}; + +export const fetchWatchlist = createAsyncThunk( + 'watchlist/fetchWatchlist', + async () => { + const response = await fetch(WATCHLISTS_API, { + headers: { + Authorization: `Bearer ${localStorage.getItem('token')}`, + }, + }); + return response.json(); + } +); + +interface IAddItem { + symbol: string; + email: string; +} + +export const addWatchlist = createAsyncThunk( + 'watchlist/addWatchlist', + async (item: IAddItem) => { + await fetch(WATCHLISTS_API, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${localStorage.getItem('token')}`, + }, + body: JSON.stringify({ + data: item, + }), + }); + } +); + +export const deleteWatchlist = createAsyncThunk( + 'watchlist/deleteWatchlist', + async (id: number) => { + await fetch(`${WATCHLISTS_API}/${id}`, { + method: 'DELETE', + headers: { + Authorization: `Bearer ${localStorage.getItem('token')}`, + }, + }); + } +); + +export const watchlistSlice = createSlice({ + name: 'watchlist', + initialState, + reducers: {}, + extraReducers: { + [fetchWatchlist.pending.type]: (state) => { + state.status = 'loading'; + state.error = ''; + }, + [fetchWatchlist.fulfilled.type]: (state, action) => { + state.status = 'succeeded'; + state.watchlist = action.payload; + }, + [fetchWatchlist.rejected.type]: (state, action) => { + state.status = 'failed'; + state.error = action.error.message; + }, + [addWatchlist.fulfilled.type]: () => {}, + [deleteWatchlist.fulfilled.type]: () => {}, + }, +}); + +export const selectWatchlistBySymbol = ( + state: WatchlistState, + symbol: string +) => state.watchlist.find((item) => item.symbol === symbol); + +const { reducer } = watchlistSlice; +export default reducer; diff --git a/yarn.lock b/yarn.lock index ea61f4d..11eaa7f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1443,6 +1443,17 @@ "lodash" "^4.17.15" "redent" "^3.0.0" +"@testing-library/react-hooks@^7.0.2": + "integrity" "sha512-dYxpz8u9m4q1TuzfcUApqi8iFfR6R0FaMbr2hjZJy1uC8z+bO/K4v8Gs9eogGKYQop7QsrBTFkv/BCF7MzD2Cg==" + "resolved" "https://registry.npmjs.org/@testing-library/react-hooks/-/react-hooks-7.0.2.tgz" + "version" "7.0.2" + dependencies: + "@babel/runtime" "^7.12.5" + "@types/react" ">=16.9.0" + "@types/react-dom" ">=16.9.0" + "@types/react-test-renderer" ">=16.9.0" + "react-error-boundary" "^3.1.0" + "@testing-library/react@12.1.2": "integrity" "sha512-ihQiEOklNyHIpo2Y8FREkyD1QAea054U0MVbwH1m8N9TxeFz+KoJ9LkqoKqJlzx2JDm56DVwaJ1r36JYxZM05g==" "resolved" "https://registry.npmjs.org/@testing-library/react/-/react-12.1.2.tgz" @@ -1605,6 +1616,13 @@ "resolved" "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.4.tgz" "version" "15.7.4" +"@types/react-dom@>=16.9.0": + "integrity" "sha512-SeJ430ndLI15JtRSHuzotn7AIdUtr8bdk6XW8mMfzjZo3vahRgJGHZqHiI4nAzCHTVG4qC21ObfsHBVUEHcDhg==" + "resolved" "https://registry.npmjs.org/@types/react-dom/-/react-dom-17.0.12.tgz" + "version" "17.0.12" + dependencies: + "@types/react" "*" + "@types/react-redux@^7.1.20", "@types/react-redux@^7.1.22": "integrity" "sha512-GxIA1kM7ClU73I6wg9IRTVwSO9GS+SAKZKe0Enj+82HMU6aoESFU2HNAdNi3+J53IaOHPiUfT3kSG4L828joDQ==" "resolved" "https://registry.npmjs.org/@types/react-redux/-/react-redux-7.1.22.tgz" @@ -1615,6 +1633,13 @@ "hoist-non-react-statics" "^3.3.0" "redux" "^4.0.0" +"@types/react-test-renderer@>=16.9.0": + "integrity" "sha512-3Fi2O6Zzq/f3QR9dRnlnHso9bMl7weKCviFmfF6B4LS1Uat6Hkm15k0ZAQuDz+UBq6B3+g+NM6IT2nr5QgPzCw==" + "resolved" "https://registry.npmjs.org/@types/react-test-renderer/-/react-test-renderer-17.0.1.tgz" + "version" "17.0.1" + dependencies: + "@types/react" "*" + "@types/react-toggle@^4.0.3": "integrity" "sha512-57QdMWeeQdRjM2/p+udgYerxUbSkmeUIW18kwUttcci6GHkgxoqCsDZfRtsCsAHcvvM5VBQdtDUEgLWo2e87mA==" "resolved" "https://registry.npmjs.org/@types/react-toggle/-/react-toggle-4.0.3.tgz" @@ -1629,7 +1654,7 @@ dependencies: "@types/react" "*" -"@types/react@*", "@types/react@^16.8.6 || ^17.0.0", "@types/react@17.0.38": +"@types/react@*", "@types/react@^16.8.6 || ^17.0.0", "@types/react@>=16.9.0", "@types/react@17.0.38": "integrity" "sha512-SI92X1IA+FMnP3qM5m4QReluXzhcmovhZnLNm3pyeQlooi02qI7sLiepEYqT678uNiyc25XfCqxREFpy3W7YhQ==" "resolved" "https://registry.npmjs.org/@types/react/-/react-17.0.38.tgz" "version" "17.0.38" @@ -1638,6 +1663,13 @@ "@types/scheduler" "*" "csstype" "^3.0.2" +"@types/redux-logger@^3.0.9": + "integrity" "sha512-cwYhVbYNgH01aepeMwhd0ABX6fhVB2rcQ9m80u8Fl50ZODhsZ8RhQArnLTkE7/Zrfq4Sz/taNoF7DQy9pCZSKg==" + "resolved" "https://registry.npmjs.org/@types/redux-logger/-/redux-logger-3.0.9.tgz" + "version" "3.0.9" + dependencies: + "redux" "^4.0.0" + "@types/scheduler@*": "integrity" "sha512-hppQEBDmlwhFAXKJX2KnWLYu5yMfi91yazPb2l+lbJiwW+wdo1gNeRA+3RgNSO39WYX2euey41KEwnqesU2Jew==" "resolved" "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.16.2.tgz" @@ -2682,6 +2714,11 @@ "resolved" "https://registry.npmjs.org/dedent/-/dedent-0.7.0.tgz" "version" "0.7.0" +"deep-diff@^0.3.5": + "integrity" "sha1-wB3mPvsO7JeYgB1Ax+Da4ltYLIQ=" + "resolved" "https://registry.npmjs.org/deep-diff/-/deep-diff-0.3.8.tgz" + "version" "0.3.8" + "deep-is@^0.1.3", "deep-is@~0.1.3": "integrity" "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==" "resolved" "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz" @@ -4900,12 +4937,17 @@ "resolved" "https://registry.npmjs.org/next-redux-wrapper/-/next-redux-wrapper-7.0.5.tgz" "version" "7.0.5" +"next-router-mock@^0.6.5": + "integrity" "sha512-gh6phWv4YUhFON0rWGmc02ni91m68ICG1HTj2N9bi2Y0MIlp5Z12QITXF4lNtV33wuMeUzrs/Ik6XyNOZ8rmNQ==" + "resolved" "https://registry.npmjs.org/next-router-mock/-/next-router-mock-0.6.5.tgz" + "version" "0.6.5" + "next-themes@^0.0.15": "integrity" "sha512-LTmtqYi03c4gMTJmWwVK9XkHL7h0/+XrtR970Ujvtu3s0kZNeJN24aJsi4rkZOI8i19+qq6f8j+8Duwy5jqcrQ==" "resolved" "https://registry.npmjs.org/next-themes/-/next-themes-0.0.15.tgz" "version" "0.0.15" -"next@*", "next@^12.0.10", "next@>=10.0.3", "next@>=10.2.0": +"next@*", "next@^12.0.10", "next@>=10.0.0", "next@>=10.0.3", "next@>=10.2.0": "integrity" "sha512-1y3PpGzpb/EZzz1jgne+JfZXKAVJUjYXwxzrADf/LWN+8yi9o79vMLXpW3mevvCHkEF2sBnIdjzNn16TJrINUw==" "resolved" "https://registry.npmjs.org/next/-/next-12.0.10.tgz" "version" "12.0.10" @@ -5350,7 +5392,7 @@ "iconv-lite" "0.4.24" "unpipe" "1.0.0" -"react-dom@*", "react-dom@^16 || ^17", "react-dom@^16.8.0 || ^17.0.0", "react-dom@^17.0.2 || ^18.0.0-0", "react-dom@>= 15.3.0 < 18", "react-dom@>=16.6.0", "react-dom@17.0.2": +"react-dom@*", "react-dom@^16 || ^17", "react-dom@^16.8.0 || ^17.0.0", "react-dom@^17.0.2 || ^18.0.0-0", "react-dom@>= 15.3.0 < 18", "react-dom@>=16.6.0", "react-dom@>=16.9.0", "react-dom@17.0.2": "integrity" "sha512-s4h96KtLDUQlsENhMn1ar8t2bEa+q/YAtj8pPPdIjPDGBDIVNsrD9aXNWqspUe6AzKCIG0C1HZZLqLV7qpOBGA==" "resolved" "https://registry.npmjs.org/react-dom/-/react-dom-17.0.2.tgz" "version" "17.0.2" @@ -5359,6 +5401,13 @@ "object-assign" "^4.1.1" "scheduler" "^0.20.2" +"react-error-boundary@^3.1.0": + "integrity" "sha512-uM9uPzZJTF6wRQORmSrvOIgt4lJ9MC1sNgEOj2XGsDTRE4kmpWxg7ENK9EWNKJRMAOY9z0MuF4yIfl6gp4sotA==" + "resolved" "https://registry.npmjs.org/react-error-boundary/-/react-error-boundary-3.1.4.tgz" + "version" "3.1.4" + dependencies: + "@babel/runtime" "^7.12.5" + "react-google-login@^5.2.2": "integrity" "sha512-JUngfvaSMcOuV0lFff7+SzJ2qviuNMQdqlsDJkUM145xkGPVIfqWXq9Ui+2Dr6jdJWH5KYdynz9+4CzKjI5u6g==" "resolved" "https://registry.npmjs.org/react-google-login/-/react-google-login-5.2.2.tgz" @@ -5411,7 +5460,7 @@ "loose-envify" "^1.4.0" "prop-types" "^15.6.2" -"react@*", "react@^16 || ^17", "react@^16.8.0 || ^17.0.0", "react@^16.8.3 || ^17", "react@^16.9.0 || ^17.0.0 || 18.0.0-beta", "react@^17.0.2 || ^18.0.0-0", "react@>= 15.3.0 < 18", "react@>= 16.8.0 || 17.x.x || 18.x.x", "react@>=16.6.0", "react@>=16.x", "react@17.0.2": +"react@*", "react@^16 || ^17", "react@^16.8.0 || ^17.0.0", "react@^16.8.3 || ^17", "react@^16.9.0 || ^17.0.0 || 18.0.0-beta", "react@^17.0.2 || ^18.0.0-0", "react@>= 15.3.0 < 18", "react@>= 16.8.0 || 17.x.x || 18.x.x", "react@>=16.13.1", "react@>=16.6.0", "react@>=16.9.0", "react@>=16.x", "react@>=17.0.0", "react@17.0.2": "integrity" "sha512-gnhPt75i/dq/z3/6q/0asP78D0u592D5L1pd7M8P+dck6Fu/jJeL6iVVK23fptSUZj8Vjf++7wXA8UNclGQcbA==" "resolved" "https://registry.npmjs.org/react/-/react-17.0.2.tgz" "version" "17.0.2" @@ -5443,6 +5492,13 @@ "indent-string" "^4.0.0" "strip-indent" "^3.0.0" +"redux-logger@^3.0.6": + "integrity" "sha1-91VZZvMJjzyIYExEnPC69XeCdL8=" + "resolved" "https://registry.npmjs.org/redux-logger/-/redux-logger-3.0.6.tgz" + "version" "3.0.6" + dependencies: + "deep-diff" "^0.3.5" + "redux-persist@^6.0.0": "integrity" "sha512-71LLMbUq2r02ng2We9S215LtPu3fY0KgaGE0k8WRgl6RkqxtGfl7HUozz1Dftwsb0D/5mZ8dwAaPbtnzfvbEwQ==" "resolved" "https://registry.npmjs.org/redux-persist/-/redux-persist-6.0.0.tgz"