diff --git a/package-lock.json b/package-lock.json index 150255d..e5ed72f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11049,6 +11049,12 @@ "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", "dev": true }, + "node_modules/synchronous-promise": { + "version": "2.0.17", + "resolved": "https://registry.npmjs.org/synchronous-promise/-/synchronous-promise-2.0.17.tgz", + "integrity": "sha512-AsS729u2RHUfEra9xJrE39peJcc2stq2+poBXX8bcM08Y6g9j/i/PUzwNQqkaJde7Ntg1TO7bSREbR5sdosQ+g==", + "dev": true + }, "node_modules/synckit": { "version": "0.9.1", "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.9.1.tgz", diff --git a/src/__test__/dashBoard/Categories.test.tsx b/src/__test__/dashBoard/Categories.test.tsx new file mode 100644 index 0000000..a60f2fa --- /dev/null +++ b/src/__test__/dashBoard/Categories.test.tsx @@ -0,0 +1,148 @@ +import React from 'react'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { Provider } from 'react-redux'; +import { configureStore } from '@reduxjs/toolkit'; +import { MemoryRouter } from 'react-router-dom'; +import axios from 'axios'; +import MockAdapter from 'axios-mock-adapter'; +import Category from '@/components/dashBoard/Category'; +import categoriesReducer from '@/features/Products/categorySlice'; + +const renderWithProviders = (ui: React.ReactElement) => { + const store = configureStore({ reducer: { categories: categoriesReducer } }); + return render( + + {ui} + + ); +}; + +describe('Category Component', () => { + let mock: MockAdapter; + + beforeEach(() => { + mock = new MockAdapter(axios); + }); + + afterEach(() => { + mock.restore(); + }); + + it('should render the component with initial state', () => { + renderWithProviders(); + expect(screen.getByText(/Categories/i)).toBeInTheDocument(); + }); + + it('renders Categories component with category icons', async () => { + mock.onGet('/api/categories').reply(200, [ + { + id: 1, + name: 'Category 1', + description: 'Description 1', + icon: 'https://example.com/icon1.png', + }, + { + id: 2, + name: 'Category 2', + description: 'Description 2', + icon: 'https://example.com/icon2.png', + }, + ]); + + renderWithProviders(); + + const icons = await screen.findAllByRole('img'); + icons.forEach((icon) => { + expect(icon).toHaveAttribute('alt'); + }); + }); + + it('paginates categories', async () => { + mock.onGet('/api/categories').reply( + 200, + Array.from({ length: 20 }, (_, i) => ({ + id: i + 1, + name: `Category ${i + 1}`, + description: `Description ${i + 1}`, + icon: `https://example.com/icon${i + 1}.png`, + })) + ); + + renderWithProviders(); + + const nextPageButton = await screen.findByRole('button', { name: /next/i }); + fireEvent.click(nextPageButton); + expect(nextPageButton).toBeInTheDocument(); + }); + + it('should render the component with initial state', async () => { + renderWithProviders(); + await waitFor(() => + expect(screen.getByText(/Categories/i)).toBeInTheDocument() + ); + }); + + it('paginates categories', async () => { + mock.onGet('/api/categories').reply( + 200, + Array.from({ length: 20 }, (_, i) => ({ + id: i + 1, + name: `Category ${i + 1}`, + description: `Description ${i + 1}`, + icon: `https://example.com/icon${i + 1}.png`, + })) + ); + + renderWithProviders(); + + const nextPageButton = await screen.findByRole('button', { name: /next/i }); + fireEvent.click(nextPageButton); + expect(nextPageButton).toBeInTheDocument(); + }); + + it('should display validation errors if form is submitted with invalid data', async () => { + renderWithProviders(); + + // Open the form + fireEvent.click(screen.getByText(/Add Category/i)); + + // Trigger form submission + fireEvent.click(screen.getByText(/Save/i)); + + // Wait for validation errors to appear + await waitFor(() => { + expect(screen.getByText(/Name/i)).toBeInTheDocument(); + expect(screen.getByText(/Icon/i)).toBeInTheDocument(); + expect(screen.getByText(/Description/i)).toBeInTheDocument(); + }); + }); + + it('should submit form and handle API response', async () => { + mock.onPost(`${import.meta.env.VITE_BASE_URL}/category/`).reply(201); + + renderWithProviders(); + + // Open the form + fireEvent.click(screen.getByText(/Add Category/i)); + + // Fill out the form + fireEvent.change(screen.getByPlaceholderText(/Name of the category/i), { + target: { value: 'New Category' }, + }); + fireEvent.change( + screen.getByPlaceholderText(/Description of the category/i), + { + target: { value: 'Category description' }, + } + ); + fireEvent.change(screen.getByPlaceholderText(/URL of the category icon/i), { + target: { value: 'https://example.com/icon.png' }, + }); + + fireEvent.click(screen.getByText(/Save/i)); + + await waitFor(() => { + expect(mock.history.post.length).toBe(1); + }); + }); +}); diff --git a/src/__test__/dashBoard/Seller.test.tsx b/src/__test__/dashBoard/Seller.test.tsx index db47d0a..12f9118 100644 --- a/src/__test__/dashBoard/Seller.test.tsx +++ b/src/__test__/dashBoard/Seller.test.tsx @@ -1,9 +1,9 @@ import axios from 'axios'; import { Provider } from 'react-redux'; import { MemoryRouter } from 'react-router-dom'; -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, beforeEach } from 'vitest'; import MockAdapter from 'axios-mock-adapter'; -import { render, screen, fireEvent } from '@testing-library/react'; +import { render, screen, waitFor } from '@testing-library/react'; import { configureStore } from '@reduxjs/toolkit'; import Seller from '@/pages/Seller'; import productReducer from '@/app/Dashboard/AllProductSlices'; @@ -82,42 +82,25 @@ describe('Seller Component', () => { expect(screen.getByText('Sellers')).toBeInTheDocument(); }); - it('should display elements of the table', () => { - renderWithProviders(); - - expect(screen.getByText(/Image/)).toBeInTheDocument(); - expect(screen.getByText(/First Name/)).toBeInTheDocument(); - expect(screen.getByText(/Last Name/)).toBeInTheDocument(); - expect(screen.getByText(/Email/)).toBeInTheDocument(); - expect(screen.getByText(/Items Count/)).toBeInTheDocument(); - expect(screen.getByText(/Date/)).toBeInTheDocument(); - expect(screen.getByText(/Status/)).toBeInTheDocument(); - expect(screen.getByText(/Action/)).toBeInTheDocument(); - }); - - it('should filter sellers by search term', async () => { - // Mock API response + it('should display elements of the table', async () => { mock .onGet(`${import.meta.env.VITE_BASE_URL}/user/getAllUsers`) .reply(200, { users: mockBuyers }); - // Render component renderWithProviders(); - - // Dispatch fetchBuyers to populate the state await store.dispatch(fetchBuyers() as any); - // Perform search action - const searchInput = screen.getByPlaceholderText('Search Seller'); - fireEvent.change(searchInput, { target: { value: 'Vendor1' } }); - - // Check filtered results - expect(screen.getByText('Vendor1')).toBeInTheDocument(); - expect(screen.queryByText('Customer1')).not.toBeInTheDocument(); + await waitFor(() => { + expect(screen.getByText('Name')).toBeInTheDocument(); + expect(screen.getByText('Email')).toBeInTheDocument(); + expect(screen.getByText('Items')).toBeInTheDocument(); + expect(screen.getByText('Date')).toBeInTheDocument(); + expect(screen.getByText('Status')).toBeInTheDocument(); + expect(screen.getByText('Action')).toBeInTheDocument(); + }); }); it('should handle loading state', async () => { - // Mock API response mock .onGet(`${import.meta.env.VITE_BASE_URL}/user/getAllUsers`) .reply(200, { users: mockBuyers }); @@ -137,7 +120,10 @@ describe('Seller Component', () => { renderWithProviders(); await store.dispatch(fetchBuyers() as any); - expect(screen.queryByText('Vendor1')).toBeNull(); - expect(screen.queryByText('Customer1')).toBeNull(); + + await waitFor(() => { + expect(screen.queryByText('Vendor1')).toBeNull(); + expect(screen.queryByText('Customer1')).toBeNull(); + }); }); }); diff --git a/src/components/dashBoard/Category.tsx b/src/components/dashBoard/Category.tsx new file mode 100644 index 0000000..28232b1 --- /dev/null +++ b/src/components/dashBoard/Category.tsx @@ -0,0 +1,454 @@ +import React, { useEffect, useState } from 'react'; +import axios from 'axios'; +import { useDispatch, useSelector } from 'react-redux'; +import { MdOutlineEdit } from 'react-icons/md'; +import { FaRegTrashAlt } from 'react-icons/fa'; +import { IoIosSearch } from 'react-icons/io'; +import * as Yup from 'yup'; +import { ErrorMessage, Field, Form, Formik } from 'formik'; +import PuffLoader from 'react-spinners/PuffLoader'; +import CircularPagination from './NavigateonPage'; +import Button from '@/components/form/Button'; +import { showErrorToast, showSuccessToast } from '@/utils/ToastConfig'; +import HSInput from '@/components/form/HSInput'; +import { AppDispatch, RootState } from '@/app/store'; +import { fetchCategories } from '@/features/Products/categorySlice'; + +interface ICategory { + id?: number; + name: string; + description: string; + icon?: string; +} + +function Category() { + const dispatch = useDispatch(); + const [currentPage, setCurrentPage] = useState(1); + const [searchTerm, setSearchTerm] = useState(''); + const [deleteModal, setDeleteModal] = useState(false); + const [selectedCategory, setSelectedCategory] = useState( + null + ); + const [edit, setEdit] = useState(false); + const { categories, isLoading } = useSelector( + (state: RootState) => state.categories + ); + + const [visibleCategory, setVisbleCategory] = useState(false); + + useEffect(() => { + dispatch(fetchCategories()); + }, [dispatch]); + + const categoryPerPage = 8; + const totalPages = Math.ceil(categories.length / categoryPerPage); + const startIndex = (currentPage - 1) * categoryPerPage; + + const paginatedData = categories + .filter((category) => + category.description.toLowerCase().includes(searchTerm.toLowerCase()) + ) + .slice(startIndex, startIndex + categoryPerPage); + + const handlePageChange = (page: number) => { + setCurrentPage(page); + }; + + const handleCancelDelete = () => { + setDeleteModal(false); + setSelectedCategory(null); + }; + + const handleConfirmDelete = async () => { + if (selectedCategory === null) return; + + try { + const response = await axios.delete( + `${import.meta.env.VITE_BASE_URL}/category/${selectedCategory.id}`, + { + headers: { + Authorization: `Bearer ${localStorage.getItem('token')}`, + }, + } + ); + if (response.status === 200) { + showSuccessToast(' Deleted sucessfully'); + dispatch(fetchCategories()); + } + } catch (error) { + showErrorToast('Failed to delete category'); + } + }; + + const handleEdit = (category: ICategory) => { + setEdit(!edit); + setSelectedCategory(category); + }; + + const updateCategory = async (category: ICategory) => { + if (!category.id) return; + try { + const response = await axios.put( + `${import.meta.env.VITE_BASE_URL}/category/${category.id}`, + { + name: category.name, + description: category.description, + icon: category.icon, + }, + { + headers: { + Authorization: `Bearer ${localStorage.getItem('token')}`, + }, + } + ); + if (response.status === 200) { + showSuccessToast(`${category.name} Updated sucessfully`); + dispatch(fetchCategories()); + } + } catch (error) { + showErrorToast('Failed to update category'); + } + }; + + const HandleAddCategory = () => { + setVisbleCategory(true); + }; + const showDeleteModal = (category: ICategory) => { + setSelectedCategory(category); + setDeleteModal(true); + }; + + const categoryInitialVal = { + name: '', + icon: '', + description: '', + }; + const validationSchemaCategory = Yup.object({ + name: Yup.string().required('Category name is required'), + description: Yup.string().required('Category icon is required'), + icon: Yup.string().required('Category description is required'), + }); + + const onsubmiting = async ( + values: ICategory, + { resetForm }: { resetForm: () => void } + ) => { + const token = localStorage.getItem('token'); + + try { + const response = await axios.post( + `${import.meta.env.VITE_BASE_URL}/category/`, + values, + { + headers: { + Authorization: `Bearer ${token}`, + }, + } + ); + + if (response.status === 201) { + showSuccessToast('Category added successfully'); + dispatch(fetchCategories()); + resetForm(); + } + } catch (error) { + showErrorToast('Failed to add category'); + } finally { + resetForm(); + } + }; + + return ( +
+
+

Categories

+ +
+
+
    +
  • + + ({categories.length}) +
  • +
+
+ + setSearchTerm(e.target.value)} + className="w-full outline-none bg-white placeholder:text-gray-400 font-light" + /> +
+
+ + {visibleCategory && ( +
+ +
+
+
+

+ Add Category +

+ + + +
+
+ + + +
+ +
+ + + +
+
+ +
+
+
+
+
+ )} + + {edit && selectedCategory && ( +
+
+
+ + + setSelectedCategory({ + ...selectedCategory, + name: e.target.value, + }) + } + /> +
+
+ + + setSelectedCategory({ + ...selectedCategory, + description: e.target.value, + }) + } + /> +
+ +
+ + {selectedCategory.icon && ( +
+ Icon Preview +
+ )} + + + setSelectedCategory({ + ...selectedCategory, + icon: e.target.value, + }) + } + /> +
+
+
+
+
+ )} + {deleteModal && ( +
+
+
+ Are you sure you want to delete this category? +
+

{selectedCategory?.name}

+
+
+
+
+
+
+ )} + {isLoading ? ( +
+ +
+ ) : ( +
+ + + + + + + + + + + + {paginatedData.map((category, index) => ( + + + + + + + + + + + ))} + +
+ # + + Category + + Icon + + Description + + Action +
+ {index + 1 + (currentPage - 1) * categoryPerPage} + {category.name} +
+ {category.name} +
+
{category.description} + + + +
+
+ +
+
+ )} +
+ ); +} + +export default Category; diff --git a/src/components/dashBoard/DashboardSideNav.tsx b/src/components/dashBoard/DashboardSideNav.tsx index a28db4e..073f3c4 100644 --- a/src/components/dashBoard/DashboardSideNav.tsx +++ b/src/components/dashBoard/DashboardSideNav.tsx @@ -49,20 +49,9 @@ const sideBarItems = [ }, { name: 'seller', - icon: , - subItems: [ - { - name: 'All Seller', - path: '/dashboard/seller', - role: ['Admin'], - }, - { - name: 'Add New', - path: '/dashboard/addSeller', - role: ['Admin'], - }, - ], + path: '/dashboard/seller', role: ['Admin'], + icon: , }, { name: 'Products', @@ -79,7 +68,7 @@ const sideBarItems = [ role: ['Vendor'], }, { - path: '/products/categories', + path: '/dashboard/category', name: 'Categories', role: ['Admin'], }, diff --git a/src/pages/Seller.tsx b/src/pages/Seller.tsx index a2bb6d8..2635b4d 100644 --- a/src/pages/Seller.tsx +++ b/src/pages/Seller.tsx @@ -1,19 +1,12 @@ import { useEffect, useState } from 'react'; import { useDispatch, useSelector } from 'react-redux'; import axios from 'axios'; -import { - ChevronLeft, - ChevronRight, - Power, - RefreshCcw, - Search, -} from 'lucide-react'; +import { ChevronLeft, ChevronRight, Power, Search } from 'lucide-react'; import PuffLoader from 'react-spinners/PuffLoader'; import { RootState, AppDispatch } from '@/app/store'; import { fetchProducts } from '@/app/Dashboard/AllProductSlices'; import { fetchBuyers } from '@/app/Dashboard/buyerSlice'; import Button from '@/components/form/Button'; - import { showErrorToast, showSuccessToast } from '@/utils/ToastConfig'; interface Vendor { @@ -36,7 +29,6 @@ function Seller() { const [searchTerm, setSearch] = useState(''); const [filteredVendors, setFilteredVendors] = useState([]); const [reRenderTrigger, setReRenderTrigger] = useState(false); - const [clickedVendor, setClickedVendor] = useState(null); const [deactivate, setDeactivate] = useState(false); const [activate, setActivate] = useState(false); @@ -81,6 +73,7 @@ function Seller() { if (res.status === 200) { showSuccessToast(`${vendor?.firstName} Activated Successfully`); setReRenderTrigger((prev) => !prev); + dispatch(fetchBuyers()); } else { showErrorToast('Failed to Activate the vendor'); } @@ -107,6 +100,7 @@ function Seller() { if (res.status === 200) { showSuccessToast(`${vendor?.firstName} Suspended Successfully`); setReRenderTrigger((prev) => !prev); + dispatch(fetchBuyers()); } else { showErrorToast('Failed to Suspend the vendor'); } @@ -158,7 +152,7 @@ function Seller() { return (
{deactivate && ( -
+
Are you sure you want to suspend? @@ -208,7 +202,7 @@ function Seller() { )}
-
+
Sellers
-
+

All ({vendors.length})

@@ -243,174 +237,121 @@ function Seller() {

{status === 'loading' && ( -
- +
+
)} -
-
-
-
-
Image
-
- First Name -
-
- Last Name -
-
Email
-
- Items Count -
-
Date
-
Status
-
Action
-
- {vendors.length > 0 ? ( - visiblePage.map((v, id) => ( -
+ + + + + + + + + + + + + {visiblePage.map((vendor, i) => ( + + + + + + + + + ))} + +
NameEmailItemsDateStatusAction
+ seller avatar +
+ {`${vendor.firstName} ${vendor.lastName}`} +
+
{vendor.email}{ItemCount(vendor.firstName)}{DateFormat(vendor.updatedAt)} + + {vendor.status} + + +
+ {vendor.status === 'inactive' && ( + + )} + {vendor.status === 'active' && ( + + )} +
+
+
+ + {pages && + pages.map((page) => ( +
-
- - -
-
- )) - ) : ( -
No Vendor Found
- )} -
- - {pages && - pages.map((page) => ( - - ))} - -
-
-
- -
- {vendors && - visiblePage.map((v, id) => ( -
-
- -
-

- {v.firstName} {v.lastName} -

-

{v.email}

-
-
-
-

First Name: {v.firstName}

-

Items: {ItemCount(v.lastName)}

-

Date: {DateFormat(v.updatedAt)}

-

- Status: - - {v.status} - -

-
-
- - -
+ ))} +
- ))} +
+ )} + {status === 'failed' && ( +
+
Failed to fetch sellers
+
+ )}
); diff --git a/src/routes/AppRoutes.tsx b/src/routes/AppRoutes.tsx index bb9c1d1..45f4668 100644 --- a/src/routes/AppRoutes.tsx +++ b/src/routes/AppRoutes.tsx @@ -28,6 +28,7 @@ import Coupons from '@/pages/Coupons'; import EditCoupon from '@/pages/EditCoupon'; import TableUserRole from '@/components/dashBoard/UserRole'; import Customer from '@/pages/customer'; +import Category from '@/components/dashBoard/Category'; function AppRoutes() { return ( @@ -68,6 +69,7 @@ function AppRoutes() { } /> } /> } /> + } />