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
-
-
-
-
-
-
-
-
-
- - Manage Users
- - View Profiles
- - Edit Settings
-
-
- >
- )
+
+
+
+ );
}
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: () => (
+
+ ),
+}));
+
+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',
},
})