diff --git a/package-lock.json b/package-lock.json index ce958b2..b54821e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -62,6 +62,7 @@ "@commitlint/config-conventional": "^19.2.2", "@testing-library/dom": "^10.2.0", "@types/jest": "^29.5.12", + "@types/jwt-decode": "^3.1.0", "@types/node": "^20.14.8", "@types/react": "^18.2.66", "@types/react-dom": "^18.2.22", @@ -4077,6 +4078,16 @@ "resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz", "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==" }, + "node_modules/@types/jwt-decode": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@types/jwt-decode/-/jwt-decode-3.1.0.tgz", + "integrity": "sha512-tthwik7TKkou3mVnBnvVuHnHElbjtdbM63pdBCbZTirCt3WAdM73Y79mOri7+ljsS99ZVwUFZHLMxJuJnv/z1w==", + "deprecated": "This is a stub types definition. jwt-decode provides its own type definitions, so you do not need this installed.", + "dev": true, + "dependencies": { + "jwt-decode": "*" + } + }, "node_modules/@types/mute-stream": { "version": "0.0.4", "resolved": "https://registry.npmjs.org/@types/mute-stream/-/mute-stream-0.0.4.tgz", diff --git a/package.json b/package.json index 5e5e08a..dd1e331 100644 --- a/package.json +++ b/package.json @@ -70,6 +70,7 @@ "@commitlint/config-conventional": "^19.2.2", "@testing-library/dom": "^10.2.0", "@types/jest": "^29.5.12", + "@types/jwt-decode": "^3.1.0", "@types/node": "^20.14.8", "@types/react": "^18.2.66", "@types/react-dom": "^18.2.22", diff --git a/src/__test__/registerSlice.test.tsx b/src/__test__/registerSlice.test.tsx new file mode 100644 index 0000000..3d81231 --- /dev/null +++ b/src/__test__/registerSlice.test.tsx @@ -0,0 +1,108 @@ +// Imports required for testing +import { AxiosError } from "axios"; +import { createAsyncThunk } from "@reduxjs/toolkit"; + +import axios from "../redux/api/api"; +import { createUser, verifyUser } from "../redux/reducers/registerSlice"; + +jest.mock("../redux/api/api"); + +describe("registerSlice Thunks", () => { + const mockUser = { + name: "John Doe", + username: "johndoe", + email: "johndoe@example.com", + password: "password123", + }; + + const mockResponseData = { message: "User registered successfully" }; + const mockToken = "validToken123"; + + afterEach(() => { + jest.resetAllMocks(); + }); + + describe("createUser thunk", () => { + it("should handle fulfilled case", async () => { + // @ts-ignore + axios.post.mockResolvedValueOnce({ data: mockResponseData }); + + const thunk = createUser(mockUser); + const dispatch = jest.fn(); + const getState = jest.fn(); + + await thunk(dispatch, getState, null); + expect(dispatch).toHaveBeenCalled(); + }); + + it("should handle rejected case with response error data", async () => { + const errorMessage = { error: "Registration failed" }; + // @ts-ignore + axios.post.mockRejectedValueOnce({ + response: { data: errorMessage }, + message: "Request failed", + }); + + const thunk = createUser(mockUser); + const dispatch = jest.fn(); + const getState = jest.fn(); + + await thunk(dispatch, getState, null); + expect(dispatch).toHaveBeenCalled(); + }); + + it("should handle rejected case without response error data", async () => { + // @ts-ignore + axios.post.mockRejectedValueOnce(new Error("Network Error")); + + const thunk = createUser(mockUser); + const dispatch = jest.fn(); + const getState = jest.fn(); + + await thunk(dispatch, getState, null); + expect(dispatch).toHaveBeenCalled(); + }); + }); + + describe("verifyUser thunk", () => { + it("should handle fulfilled case", async () => { + // @ts-ignore + axios.get.mockResolvedValueOnce({ data: true }); + + const thunk = verifyUser(mockToken); + const dispatch = jest.fn(); + const getState = jest.fn(); + + await thunk(dispatch, getState, null); + expect(dispatch).toHaveBeenCalled(); + }); + + it("should handle rejected case with response error data", async () => { + const errorMessage = { error: "Verification failed" }; + // @ts-ignore + axios.get.mockRejectedValueOnce({ + response: { data: errorMessage }, + message: "Request failed", + }); + + const thunk = verifyUser(mockToken); + const dispatch = jest.fn(); + const getState = jest.fn(); + + await thunk(dispatch, getState, null); + expect(dispatch).toHaveBeenCalled(); + }); + + it("should handle rejected case without response error data", async () => { + // @ts-ignore + axios.get.mockRejectedValueOnce(new Error("Network Error")); + + const thunk = verifyUser(mockToken); + const dispatch = jest.fn(); + const getState = jest.fn(); + + await thunk(dispatch, getState, null); + expect(dispatch).toHaveBeenCalled(); + }); + }); +}); diff --git a/src/components/dashboard/ConfirmModal.tsx b/src/components/dashboard/ConfirmModal.tsx index ee56612..49debe3 100644 --- a/src/components/dashboard/ConfirmModal.tsx +++ b/src/components/dashboard/ConfirmModal.tsx @@ -6,6 +6,7 @@ interface ConfirmDeleteModalProps { message: string; product: any; loading: boolean; + text: string; } const ConfirmModal: React.FC = ({ @@ -14,6 +15,7 @@ const ConfirmModal: React.FC = ({ message, product, loading, + text, }) => (
@@ -40,7 +42,7 @@ const ConfirmModal: React.FC = ({ className="bg-red-600 text-white px-4 py-2 rounded" onClick={onConfirm} > - {loading ? "Loading..." : "Delete"} + {loading ? "Loading..." : text}
diff --git a/src/components/dashboard/products/ProductsTable.tsx b/src/components/dashboard/products/ProductsTable.tsx index a20b88e..c5e8492 100644 --- a/src/components/dashboard/products/ProductsTable.tsx +++ b/src/components/dashboard/products/ProductsTable.tsx @@ -13,6 +13,7 @@ import Spinner from "../Spinner"; import { deleteProduct, fetchProducts, + isProductAvailable, } from "../../../redux/reducers/productsSlice"; import { useAppDispatch } from "../../../redux/hooks"; import ToggleSwitch from "../ToggleSwitch"; @@ -31,12 +32,16 @@ const ProductsTable: React.FC = () => { useEffect(() => { const sorted = [...products].sort( - (a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(), + (a, b) => + new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(), ); setSortedProducts(sorted); }, [products]); const [confirmDeleteModal, setConfirmModal] = useState(false); + const [confirmTaggleModal, setConfirmTaggleModal] = useState(false); + const [isAvailable, setIsAvailable] = useState(true); + const [taggleLoading, setTaggleLoading] = useState(false); const [currentPage, setCurrentPage] = useState(1); const [itemsPerPage, setItemsPerPage] = useState(5); @@ -59,8 +64,34 @@ const ProductsTable: React.FC = () => { setCurrentPage(1); }; - const handleToggle = (id: number) => { - console.log("id", id); + const handleCancelTaggle = () => { + setConfirmTaggleModal(false); + }; + + const handleTaggleClick = (product: any) => { + setSelectedProduct(product); + setConfirmTaggleModal(true); + }; + + const handleToggle = async () => { + if (selectedProduct !== null) { + try { + setTaggleLoading(true); + const response = await dispatch( + // @ts-ignore + isProductAvailable(selectedProduct.id), + ).unwrap(); + setTaggleLoading(false); + setIsAvailable(false); + setConfirmTaggleModal(false); + await dispatch(fetchProducts()); + toast.success( + `Product was successfully ${isAvailable ? "Disabled" : "Enabled"}`, + ); + } catch (err: any) { + toast.error(err.message); + } + } }; const handleCancelDelete = () => { @@ -166,7 +197,7 @@ const ProductsTable: React.FC = () => { handleToggle(item.id)} + onChange={() => handleTaggleClick(item)} /> @@ -232,10 +263,21 @@ const ProductsTable: React.FC = () => { onConfirm={handleConfirmDelete} product={selectedProduct} loading={loadingDelete} + text="Delete" onCancel={handleCancelDelete} message="Are you sure you want to delete this product?" /> )} + {confirmTaggleModal && ( + + )} ); }; diff --git a/src/pages/Wishes.tsx b/src/pages/Wishes.tsx index 2e13096..3d979ae 100644 --- a/src/pages/Wishes.tsx +++ b/src/pages/Wishes.tsx @@ -79,13 +79,7 @@ const BuyerWishesList: React.FC = () => {

); } - if (!loggedInUserToken) { - navigate("/login"); - } - if (loggedInUserToken && loggedInUser.roleId !== 1) { - navigate("/"); - } return (
diff --git a/src/redux/reducers/productsSlice.ts b/src/redux/reducers/productsSlice.ts index bc30a27..706641b 100644 --- a/src/redux/reducers/productsSlice.ts +++ b/src/redux/reducers/productsSlice.ts @@ -79,6 +79,26 @@ SearchParams }, ); +export const isProductAvailable = createAsyncThunk( + "products/avail", + async (id: number, { rejectWithValue }) => { + try { + const response = await api.patch( + `/products/${id}/status`, + {}, + { + headers: { + Authorization: `Bearer ${localStorage.getItem("accessToken")}`, + }, + }, + ); + return response.data; + } catch (err) { + return rejectWithValue(err); + } + }, +); + const initialState: ProductsState = { loading: false, data: [], @@ -96,7 +116,9 @@ const productsSlice = createSlice({ extraReducers: (builder) => { builder .addCase(fetchProducts.pending, (state) => { - state.loading = true; + if (state.data.length === 0) { + state.loading = true; + } }) .addCase(fetchProducts.fulfilled, (state, action) => { state.loading = false; diff --git a/src/redux/reducers/wishListSlice.ts b/src/redux/reducers/wishListSlice.ts index 1bfb02f..d717bf5 100644 --- a/src/redux/reducers/wishListSlice.ts +++ b/src/redux/reducers/wishListSlice.ts @@ -44,7 +44,14 @@ void, }); return response.data.wishes; } catch (error: any) { - return rejectWithValue(error.message || "Failed to fetch wishes"); + if (axios.isAxiosError(error)) { + const axiosError = error as AxiosError; + return rejectWithValue( + // @ts-ignore + axiosError.response?.data?.message ?? "Unknown error occurred", + ); + } + return rejectWithValue("Unknown error occurred"); } }); diff --git a/src/utils/isTokenExpired.ts b/src/utils/isTokenExpired.ts new file mode 100644 index 0000000..8000271 --- /dev/null +++ b/src/utils/isTokenExpired.ts @@ -0,0 +1,24 @@ +import { decodeToken } from "react-jwt"; + +interface DecodedToken { + exp: number; + [key: string]: any; +} + +const isTokenExpired = (token: string | null) => { + if (!token) { + return true; + } + try { + const decodedToken = decodeToken(token); + const currentTime = Date.now() / 1000; + if (decodedToken) { + return decodedToken.exp < currentTime; + } + return false; + } catch (error) { + return true; + } +}; + +export default isTokenExpired;