diff --git a/package-lock.json b/package-lock.json index 8057543..4821fa6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,8 +10,10 @@ "dependencies": { "@tailwindcss/vite": "^4.1.18", "ag-grid-community": "^35.0.1", + "ag-grid-enterprise": "^35.0.1", "ag-grid-react": "^35.0.1", "axios": "^1.13.4", + "clsx": "^2.1.1", "react": "^19.2.0", "react-dom": "^19.2.0", "tailwindcss": "^4.1.18" @@ -2426,6 +2428,46 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/ag-charts-community": { + "version": "13.0.1", + "resolved": "https://registry.npmjs.org/ag-charts-community/-/ag-charts-community-13.0.1.tgz", + "integrity": "sha512-N9DSUcpRFIWjXu1g1za18rZdumI1u1bEBuseL+Rh2ldNk/prKCdZifwo4Tsck0jnVDljyXh/G29nbmzmoXmdHQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "ag-charts-core": "13.0.1", + "ag-charts-locale": "13.0.1", + "ag-charts-types": "13.0.1" + } + }, + "node_modules/ag-charts-core": { + "version": "13.0.1", + "resolved": "https://registry.npmjs.org/ag-charts-core/-/ag-charts-core-13.0.1.tgz", + "integrity": "sha512-z6hqKW22nWoIBQJwcd62Z6nMdBE9uc0VLYVAmNdyHbc5qLkcln2s6zGWOo3Y0QIifdDM1dr4ZXcyVjGHudAVtw==", + "license": "MIT", + "optional": true, + "dependencies": { + "ag-charts-types": "13.0.1" + } + }, + "node_modules/ag-charts-enterprise": { + "version": "13.0.1", + "resolved": "https://registry.npmjs.org/ag-charts-enterprise/-/ag-charts-enterprise-13.0.1.tgz", + "integrity": "sha512-70InfMENfHSIoYVrQ9yKbXQCb9uA5qlYOnkYb6yjPFykE6ZSSuOGRI+dFNXQYAbC00wPJKgToX2jXQl5F4+UMA==", + "license": "Commercial", + "optional": true, + "dependencies": { + "ag-charts-community": "13.0.1", + "ag-charts-core": "13.0.1" + } + }, + "node_modules/ag-charts-locale": { + "version": "13.0.1", + "resolved": "https://registry.npmjs.org/ag-charts-locale/-/ag-charts-locale-13.0.1.tgz", + "integrity": "sha512-hr2qFe/rQSJVaRWu+Pj9CfbT5Xb7isHqqrW9eOnXFwkkGZTVM1zJai8DyMcrurC9kbRV2e3cf0toQnHkAzsxdg==", + "license": "MIT", + "optional": true + }, "node_modules/ag-charts-types": { "version": "13.0.1", "resolved": "https://registry.npmjs.org/ag-charts-types/-/ag-charts-types-13.0.1.tgz", @@ -2441,6 +2483,19 @@ "ag-charts-types": "13.0.1" } }, + "node_modules/ag-grid-enterprise": { + "version": "35.0.1", + "resolved": "https://registry.npmjs.org/ag-grid-enterprise/-/ag-grid-enterprise-35.0.1.tgz", + "integrity": "sha512-9mLwf9Bq7+H3EdB6CDO7WqJMQ/YbtpbF+jfX+G8kr9o/m6vgL46E8h7DIEg7gE6QS+IlfY9byLtgoNU8P1PeyQ==", + "license": "Commercial", + "dependencies": { + "ag-grid-community": "35.0.1" + }, + "optionalDependencies": { + "ag-charts-community": "13.0.1", + "ag-charts-enterprise": "13.0.1" + } + }, "node_modules/ag-grid-react": { "version": "35.0.1", "resolved": "https://registry.npmjs.org/ag-grid-react/-/ag-grid-react-35.0.1.tgz", @@ -2696,6 +2751,15 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", diff --git a/package.json b/package.json index 3ebd843..947d9a2 100644 --- a/package.json +++ b/package.json @@ -13,8 +13,10 @@ "dependencies": { "@tailwindcss/vite": "^4.1.18", "ag-grid-community": "^35.0.1", + "ag-grid-enterprise": "^35.0.1", "ag-grid-react": "^35.0.1", "axios": "^1.13.4", + "clsx": "^2.1.1", "react": "^19.2.0", "react-dom": "^19.2.0", "tailwindcss": "^4.1.18" diff --git a/src/App.tsx b/src/App.tsx index e73c207..aebbd61 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,27 +1,12 @@ -import './index.css' - +import './index.css'; +import { UsersGrid } from './features/users-grid/components/UsersGrid'; function App() { return ( - <> -
-

User Management System

-
-
- -
-
- -
-
- -
- - ) +
+ +
+ ); } export default App diff --git a/src/features/users-grid/components/Button.tsx b/src/features/users-grid/components/Button.tsx new file mode 100644 index 0000000..66a8df4 --- /dev/null +++ b/src/features/users-grid/components/Button.tsx @@ -0,0 +1,25 @@ +import clsx from "clsx"; + +type ButtonProps = { + label: string; + onClick: () => void; + isActive?: boolean; +}; + +export function Button({ label, onClick, isActive }: ButtonProps) { + const base = "px-4 py-2 text-sm rounded transition font-medium"; + + return ( + + ); +} diff --git a/src/features/users-grid/components/UsersGrid.test.tsx b/src/features/users-grid/components/UsersGrid.test.tsx new file mode 100644 index 0000000..e155704 --- /dev/null +++ b/src/features/users-grid/components/UsersGrid.test.tsx @@ -0,0 +1,79 @@ +import { describe, it, expect, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; + +import { UsersGrid } from "./UsersGrid"; +import type { UserProps } from "../types/users.types"; + +const mockUsers: UserProps[] = [ + { + id: 1, + firstName: "John", + lastName: "Doe", + maidenName: "Smith", + age: 30, + gender: "male", + email: "john@test.com", + phone: "1234567890", + username: "john doe", + birthDate: "1994-01-01", + bloodGroup: "O+", + height: 180, + weight: 75, + eyeColor: "Blue", + isActive: true, + }, +]; + +vi.mock("../hooks/useUsersGrid", () => ({ + useUsersApi: () => ({ + users: mockUsers, + isLoading: false, + }), +})); + +vi.mock("ag-grid-react", () => ({ + AgGridReact: () => ( +
+
First Name
+
John
+
+ ), +})); + +const renderComponent = () => render(); + +describe("UsersGrid", () => { + it("renders the grid heading", () => { + renderComponent(); + + expect( + screen.getByRole("heading", { + name: /user management grid/i, + }), + ).toBeInTheDocument(); + }); + + it("renders column headers", () => { + renderComponent(); + + expect(screen.getByText("First Name")).toBeInTheDocument(); + }); + + it("renders user data in the grid", () => { + renderComponent(); + + expect(screen.getByText("John")).toBeInTheDocument(); + }); + + it("renders filter toggle buttons", () => { + renderComponent(); + + expect( + screen.getByRole("button", { name: /show filters/i }), + ).toBeInTheDocument(); + + expect( + screen.getByRole("button", { name: /hide filters/i }), + ).toBeInTheDocument(); + }); +}); diff --git a/src/features/users-grid/components/UsersGrid.tsx b/src/features/users-grid/components/UsersGrid.tsx new file mode 100644 index 0000000..ab7f381 --- /dev/null +++ b/src/features/users-grid/components/UsersGrid.tsx @@ -0,0 +1,75 @@ +import { useState } from "react"; +import { AgGridReact } from "ag-grid-react"; +import { ModuleRegistry, AllCommunityModule } from "ag-grid-community"; +import { SetFilterModule } from "ag-grid-enterprise"; +import { useUsersApi } from "../hooks/useUsersGrid"; +import type { UserProps } from "../types/users.types"; + +import { + DEFAULT_PAGINATION_PAGE_SIZE, + PAGINATION_PAGE_SIZE_OPTIONS, +} from "../constants/users.constants"; + +import "ag-grid-community/styles/ag-theme-alpine.css"; +import { getUserColumnDefs, getDefaultColDef } from "../utils/userColumnDefs"; +import { Button } from "./Button"; + +ModuleRegistry.registerModules([AllCommunityModule, SetFilterModule]); + +export function UsersGrid() { + const { users: userData, isLoading } = useUsersApi(); + const [showFilters, setShowFilters] = useState(false); + + const columnDefs = getUserColumnDefs(showFilters); + const defaultColDef = getDefaultColDef(showFilters); + + const filterButtons = [ + { + label: "Show Filters", + isActive: showFilters, + onClick: () => setShowFilters(true), + }, + { + label: "Hide Filters", + isActive: !showFilters, + onClick: () => setShowFilters(false), + }, + ]; + + return ( +
+ {/* Header */} +
+

+ 👥 User Management Grid +

+ +
+ {filterButtons.map(({ label, onClick, isActive }) => ( +
+
+ + {/* Grid Wrapper */} +
+ + className="ag-theme-alpine min-w-300 rounded-lg border shadow-sm bg-violet-500" + containerStyle={{ height: 600 }} + rowData={userData} + columnDefs={columnDefs} + defaultColDef={defaultColDef} + loading={isLoading} + pagination + paginationPageSize={DEFAULT_PAGINATION_PAGE_SIZE} + paginationPageSizeSelector={PAGINATION_PAGE_SIZE_OPTIONS} + /> +
+
+ ); +} diff --git a/src/features/users-grid/constants/users.constants.ts b/src/features/users-grid/constants/users.constants.ts new file mode 100644 index 0000000..d08918b --- /dev/null +++ b/src/features/users-grid/constants/users.constants.ts @@ -0,0 +1,13 @@ +export const USERS_API_BASE_URL = 'https://dummyjson.com'; + +export const DEFAULT_PAGINATION_PAGE_SIZE = 10; +export const PAGINATION_PAGE_SIZE_OPTIONS = [10, 20, 50, 100]; + +export const Gender = { + Male: 'male', + Female: 'female', +} as const; + +export type Gender = (typeof Gender)[keyof typeof Gender]; + +export const GENDER_OPTIONS = [Gender.Male, Gender.Female]; diff --git a/src/features/users-grid/hooks/useUsersGrid.ts b/src/features/users-grid/hooks/useUsersGrid.ts new file mode 100644 index 0000000..1468f03 --- /dev/null +++ b/src/features/users-grid/hooks/useUsersGrid.ts @@ -0,0 +1,55 @@ +import { useEffect, useState } from 'react'; +import type { UserProps } from '../types/users.types'; +import { USERS_API_BASE_URL } from '../constants/users.constants'; +import axios from 'axios'; + +export function useUsersApi() { + const [users, setUsers] = useState([]); + const [isLoading, setIsLoading] = useState(false); + + useEffect(function () { + let isMounted = true; + const abortController = new AbortController(); + + async function fetchUsersOnMount() { + setIsLoading(true); + + try { + const response = await axios.get<{ users: UserProps[] }>(`${USERS_API_BASE_URL}/users`, { + signal: abortController.signal, + }); + + if (isMounted) { + const mappedUsers = response.data.users.map(function (user: UserProps) { + return { + ...user, + isActive: user.age < 50, + }; + }); + + setUsers(mappedUsers); + } + } catch (error) { + if (isMounted && !axios.isCancel(error)) { + console.error('Failed to load users', error); + } + } finally { + if (isMounted) { + setIsLoading(false); + } + } + } + + fetchUsersOnMount(); + + return function () { + isMounted = false; + abortController.abort(); + }; + }, []); + + return { + users, + isLoading, + }; +} \ No newline at end of file diff --git a/src/features/users-grid/types/users.types.ts b/src/features/users-grid/types/users.types.ts new file mode 100644 index 0000000..dc04c2a --- /dev/null +++ b/src/features/users-grid/types/users.types.ts @@ -0,0 +1,23 @@ +import type { Gender } from '../constants/users.constants'; + +export interface UserProps { + id: number; + firstName: string; + lastName: string; + maidenName: string; + age: number; + gender: Gender; + email: string; + phone: string; + username: string; + birthDate: string; + bloodGroup: string; + height: number; + weight: number; + eyeColor: string; + isActive: boolean; +} + +export interface UsersResponseProps { + users: UserProps[]; +} diff --git a/src/features/users-grid/utils/userColumnDefs.ts b/src/features/users-grid/utils/userColumnDefs.ts new file mode 100644 index 0000000..21a6abe --- /dev/null +++ b/src/features/users-grid/utils/userColumnDefs.ts @@ -0,0 +1,118 @@ +import type { ColDef } from 'ag-grid-community' +import type { UserProps } from '../types/users.types' + +export function getUserColumnDefs( + showFilters: boolean +): ColDef[] { + return [ + { + field: 'id', + headerName: 'ID', + width: 90, + filter: showFilters ? 'agNumberColumnFilter' : false, + }, + { + field: 'firstName', + headerName: 'First Name', + minWidth: 160, + filter: showFilters ? 'agTextColumnFilter' : false, + }, + { + field: 'lastName', + headerName: 'Last Name', + minWidth: 160, + filter: showFilters ? 'agTextColumnFilter' : false, + }, + { + field: 'maidenName', + headerName: 'Maiden Name', + minWidth: 160, + filter: showFilters ? 'agTextColumnFilter' : false, + }, + { + field: 'age', + headerName: 'Age', + width: 100, + filter: showFilters ? 'agNumberColumnFilter' : false, + }, + { + field: 'gender', + headerName: 'Gender', + minWidth: 140, + filter: showFilters ? 'agSetColumnFilter' : false, + }, + { + field: 'email', + headerName: 'Email', + minWidth: 240, + filter: showFilters ? 'agTextColumnFilter' : false, + }, + { + field: 'phone', + headerName: 'Phone', + minWidth: 160, + filter: showFilters ? 'agTextColumnFilter' : false, + }, + { + field: 'username', + headerName: 'Username', + minWidth: 140, + filter: showFilters ? 'agTextColumnFilter' : false, + }, + { + field: 'birthDate', + headerName: 'Birth Date', + minWidth: 140, + filter: showFilters ? 'agTextColumnFilter' : false, + }, + { + field: 'bloodGroup', + headerName: 'Blood Group', + minWidth: 120, + filter: showFilters ? 'agTextColumnFilter' : false, + }, + { + field: 'height', + headerName: 'Height', + width: 100, + filter: showFilters ? 'agNumberColumnFilter' : false, + }, + { + field: 'weight', + headerName: 'Weight', + width: 100, + filter: showFilters ? 'agNumberColumnFilter' : false, + }, + { + field: 'eyeColor', + headerName: 'Eye Color', + minWidth: 120, + filter: showFilters ? 'agTextColumnFilter' : false, + cellStyle: (params) => { + const color = String(params.value).toLowerCase(); + const backgroundMap: Record = { + blue: '#dbeafe', + brown: '#fef3c7', + green: '#dcfce7', + hazel: '#fef9c3', + gray: '#e5e7eb', + black: '#e5e7eb', + }; + return { + backgroundColor: backgroundMap[color] ?? '#f3f4f6', + color: '#111827', + fontWeight: 500, + textTransform: 'capitalize', + }; + }, + }, + ] +} +export function getDefaultColDef(showFilters: boolean) { + return { + sortable: true, + resizable: true, + filter: showFilters, + floatingFilter: showFilters, + }; +} diff --git a/src/index.css b/src/index.css index a461c50..92e5370 100644 --- a/src/index.css +++ b/src/index.css @@ -1 +1,8 @@ -@import "tailwindcss"; \ No newline at end of file + +@import "tailwindcss"; + +html, body { + height: 100%; + overflow: hidden; +} + diff --git a/vite.config.ts b/vite.config.ts index 7ef027f..7421d35 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -2,13 +2,12 @@ import { defineConfig } from 'vitest/config' import react from '@vitejs/plugin-react' import tailwindcss from '@tailwindcss/vite' -// https://vite.dev/config/ export default defineConfig({ plugins: [react(), tailwindcss()], test: { - // globals: true, + globals: true, environment: 'jsdom', - setupFiles: './src/tests/setup.ts', + setupFiles: './src/test/setup.ts', }, })