(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"