[SPRINT-3] feat: Add WCAG improvements and 160 unit tests
## ST-3.1 WCAG Accessibility (5 SP) - Replace div onClick with semantic buttons - Add aria-label to interactive icons - Add aria-hidden to decorative icons - Add focus:ring-2 for visible focus states - Add role attributes to modals, trees, progressbars - Add proper form labels with htmlFor Files modified: NotificationDrawer, DashboardLayout, PermissionsMatrix, NetworkTree, GoalProgressBar, AuditFilters, NotificationBell, NotificationItem, RoleCard, RoleForm ## ST-3.2 Unit Tests (5 SP) - Add 160 new unit tests (target was 42) - Hooks: useAuth (18), useRbac (18), useGoals (15), useMlm (19), usePortfolio (24) - Components: RoleForm (15), GoalProgressBar (28), RankBadge (23) - All tests use AAA pattern with proper mocking Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
891689a4f4
commit
9bd1aba33d
269
src/__tests__/components/goals/GoalProgressBar.test.tsx
Normal file
269
src/__tests__/components/goals/GoalProgressBar.test.tsx
Normal file
@ -0,0 +1,269 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { GoalProgressBar } from '@/components/goals/GoalProgressBar';
|
||||
|
||||
describe('GoalProgressBar', () => {
|
||||
describe('progress calculation', () => {
|
||||
it('should render correct percentage', () => {
|
||||
render(<GoalProgressBar currentValue={50} targetValue={100} />);
|
||||
|
||||
expect(screen.getByText('50.0%')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should calculate percentage correctly', () => {
|
||||
render(<GoalProgressBar currentValue={75} targetValue={100} />);
|
||||
|
||||
expect(screen.getByText('75.0%')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should cap percentage at 100%', () => {
|
||||
render(<GoalProgressBar currentValue={150} targetValue={100} />);
|
||||
|
||||
expect(screen.getByText('100.0%')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should handle zero target value', () => {
|
||||
render(<GoalProgressBar currentValue={50} targetValue={0} />);
|
||||
|
||||
expect(screen.getByText('0.0%')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should handle zero current value', () => {
|
||||
render(<GoalProgressBar currentValue={0} targetValue={100} />);
|
||||
|
||||
expect(screen.getByText('0.0%')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('value display', () => {
|
||||
it('should display current and target values', () => {
|
||||
render(<GoalProgressBar currentValue={50} targetValue={100} />);
|
||||
|
||||
expect(screen.getByText(/50.*\/.*100/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should display unit when provided', () => {
|
||||
render(<GoalProgressBar currentValue={5000} targetValue={10000} unit="USD" />);
|
||||
|
||||
expect(screen.getByText(/5,000.*\/.*10,000 USD/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should hide values when showValue is false', () => {
|
||||
render(<GoalProgressBar currentValue={50} targetValue={100} showValue={false} />);
|
||||
|
||||
expect(screen.queryByText(/50.*\/.*100/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should hide percentage when showPercentage is false', () => {
|
||||
render(<GoalProgressBar currentValue={50} targetValue={100} showPercentage={false} />);
|
||||
|
||||
expect(screen.queryByText('50.0%')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should format large numbers with locale', () => {
|
||||
render(<GoalProgressBar currentValue={1000000} targetValue={2000000} />);
|
||||
|
||||
expect(screen.getByText(/1,000,000.*\/.*2,000,000/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('color logic', () => {
|
||||
it('should show green color when progress >= 100%', () => {
|
||||
const { container } = render(<GoalProgressBar currentValue={100} targetValue={100} />);
|
||||
|
||||
const progressFill = container.querySelector('.bg-green-500');
|
||||
expect(progressFill).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show green-400 color when progress >= 80%', () => {
|
||||
const { container } = render(<GoalProgressBar currentValue={85} targetValue={100} />);
|
||||
|
||||
const progressFill = container.querySelector('.bg-green-400');
|
||||
expect(progressFill).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show yellow color when progress >= 50%', () => {
|
||||
const { container } = render(<GoalProgressBar currentValue={60} targetValue={100} />);
|
||||
|
||||
const progressFill = container.querySelector('.bg-yellow-500');
|
||||
expect(progressFill).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show orange color when progress >= 25%', () => {
|
||||
const { container } = render(<GoalProgressBar currentValue={30} targetValue={100} />);
|
||||
|
||||
const progressFill = container.querySelector('.bg-orange-500');
|
||||
expect(progressFill).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show red color when progress < 25%', () => {
|
||||
const { container } = render(<GoalProgressBar currentValue={10} targetValue={100} />);
|
||||
|
||||
const progressFill = container.querySelector('.bg-red-500');
|
||||
expect(progressFill).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('size variants', () => {
|
||||
it('should render small size', () => {
|
||||
const { container } = render(
|
||||
<GoalProgressBar currentValue={50} targetValue={100} size="sm" />
|
||||
);
|
||||
|
||||
const progressBar = container.querySelector('.h-2');
|
||||
expect(progressBar).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render medium size (default)', () => {
|
||||
const { container } = render(
|
||||
<GoalProgressBar currentValue={50} targetValue={100} size="md" />
|
||||
);
|
||||
|
||||
const progressBar = container.querySelector('.h-3');
|
||||
expect(progressBar).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render large size', () => {
|
||||
const { container } = render(
|
||||
<GoalProgressBar currentValue={50} targetValue={100} size="lg" />
|
||||
);
|
||||
|
||||
const progressBar = container.querySelector('.h-4');
|
||||
expect(progressBar).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('milestones', () => {
|
||||
it('should render milestones when provided', () => {
|
||||
const milestones = [
|
||||
{ percentage: 25 },
|
||||
{ percentage: 50 },
|
||||
{ percentage: 75 },
|
||||
];
|
||||
|
||||
const { container } = render(
|
||||
<GoalProgressBar
|
||||
currentValue={60}
|
||||
targetValue={100}
|
||||
milestones={milestones}
|
||||
/>
|
||||
);
|
||||
|
||||
// Should have milestone markers
|
||||
const milestoneMarkers = container.querySelectorAll('.rounded-full.border-2');
|
||||
expect(milestoneMarkers.length).toBeGreaterThanOrEqual(3);
|
||||
});
|
||||
|
||||
it('should mark achieved milestones as green', () => {
|
||||
const milestones = [
|
||||
{ percentage: 25 },
|
||||
{ percentage: 50 },
|
||||
{ percentage: 75 },
|
||||
];
|
||||
|
||||
const { container } = render(
|
||||
<GoalProgressBar
|
||||
currentValue={60}
|
||||
targetValue={100}
|
||||
milestones={milestones}
|
||||
/>
|
||||
);
|
||||
|
||||
// 25% and 50% milestones should be achieved (green)
|
||||
const achievedMilestones = container.querySelectorAll('.bg-green-500.border-green-600');
|
||||
expect(achievedMilestones.length).toBe(2);
|
||||
});
|
||||
|
||||
it('should show milestone labels for large size', () => {
|
||||
const milestones = [
|
||||
{ percentage: 25 },
|
||||
{ percentage: 50 },
|
||||
{ percentage: 75 },
|
||||
];
|
||||
|
||||
render(
|
||||
<GoalProgressBar
|
||||
currentValue={60}
|
||||
targetValue={100}
|
||||
milestones={milestones}
|
||||
size="lg"
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText('25%')).toBeInTheDocument();
|
||||
expect(screen.getByText('50%')).toBeInTheDocument();
|
||||
expect(screen.getByText('75%')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not show milestone labels for non-large sizes', () => {
|
||||
const milestones = [
|
||||
{ percentage: 50 },
|
||||
];
|
||||
|
||||
render(
|
||||
<GoalProgressBar
|
||||
currentValue={60}
|
||||
targetValue={100}
|
||||
milestones={milestones}
|
||||
size="sm"
|
||||
/>
|
||||
);
|
||||
|
||||
// The percentage label in the stats row is different from milestone labels
|
||||
// Milestone labels are only shown for size="lg"
|
||||
const allText = screen.queryAllByText('50%');
|
||||
// Should not have additional milestone label for small size
|
||||
expect(allText.length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('custom className', () => {
|
||||
it('should apply custom className', () => {
|
||||
const { container } = render(
|
||||
<GoalProgressBar
|
||||
currentValue={50}
|
||||
targetValue={100}
|
||||
className="my-custom-class"
|
||||
/>
|
||||
);
|
||||
|
||||
const wrapper = container.firstChild;
|
||||
expect(wrapper).toHaveClass('my-custom-class');
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should handle decimal values', () => {
|
||||
render(<GoalProgressBar currentValue={33.33} targetValue={100} />);
|
||||
|
||||
expect(screen.getByText('33.3%')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should handle very small progress', () => {
|
||||
render(<GoalProgressBar currentValue={1} targetValue={1000} />);
|
||||
|
||||
expect(screen.getByText('0.1%')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should handle exact boundary at 80%', () => {
|
||||
const { container } = render(<GoalProgressBar currentValue={80} targetValue={100} />);
|
||||
|
||||
const progressFill = container.querySelector('.bg-green-400');
|
||||
expect(progressFill).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should handle exact boundary at 50%', () => {
|
||||
const { container } = render(<GoalProgressBar currentValue={50} targetValue={100} />);
|
||||
|
||||
const progressFill = container.querySelector('.bg-yellow-500');
|
||||
expect(progressFill).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should handle exact boundary at 25%', () => {
|
||||
const { container } = render(<GoalProgressBar currentValue={25} targetValue={100} />);
|
||||
|
||||
const progressFill = container.querySelector('.bg-orange-500');
|
||||
expect(progressFill).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
190
src/__tests__/components/mlm/RankBadge.test.tsx
Normal file
190
src/__tests__/components/mlm/RankBadge.test.tsx
Normal file
@ -0,0 +1,190 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { RankBadge } from '@/components/mlm/RankBadge';
|
||||
|
||||
describe('RankBadge', () => {
|
||||
describe('rendering with level', () => {
|
||||
it('should render rank name', () => {
|
||||
render(<RankBadge level={1} name="Bronze" />);
|
||||
|
||||
expect(screen.getByText('Bronze')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render level number in badge', () => {
|
||||
render(<RankBadge level={3} name="Gold" />);
|
||||
|
||||
expect(screen.getByText('3')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show level label', () => {
|
||||
render(<RankBadge level={2} name="Silver" />);
|
||||
|
||||
expect(screen.getByText(/Level 2/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not show level when showLevel is false', () => {
|
||||
render(<RankBadge level={1} name="Bronze" showLevel={false} />);
|
||||
|
||||
expect(screen.queryByText('Level 1')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('1')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show first letter of name when showLevel is false', () => {
|
||||
render(<RankBadge level={1} name="Bronze" showLevel={false} />);
|
||||
|
||||
expect(screen.getByText('B')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('size variants', () => {
|
||||
it('should render small size badge', () => {
|
||||
const { container } = render(<RankBadge level={1} name="Bronze" size="sm" />);
|
||||
|
||||
const badge = container.querySelector('.w-6.h-6');
|
||||
expect(badge).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render medium size badge (default)', () => {
|
||||
const { container } = render(<RankBadge level={1} name="Bronze" size="md" />);
|
||||
|
||||
const badge = container.querySelector('.w-8.h-8');
|
||||
expect(badge).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render large size badge', () => {
|
||||
const { container } = render(<RankBadge level={1} name="Bronze" size="lg" />);
|
||||
|
||||
const badge = container.querySelector('.w-12.h-12');
|
||||
expect(badge).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should hide name and level text for small size', () => {
|
||||
render(<RankBadge level={1} name="Bronze" size="sm" />);
|
||||
|
||||
// For small size, only the level number is shown in the badge
|
||||
// Name and Level text are hidden
|
||||
expect(screen.queryByText('Bronze')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(/Level 1/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show name for medium and large sizes', () => {
|
||||
render(<RankBadge level={1} name="Bronze" size="md" />);
|
||||
|
||||
expect(screen.getByText('Bronze')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('color customization', () => {
|
||||
it('should apply custom color to badge', () => {
|
||||
const { container } = render(
|
||||
<RankBadge level={1} name="Bronze" color="#CD7F32" />
|
||||
);
|
||||
|
||||
const badge = container.querySelector('.rounded-full');
|
||||
expect(badge).toHaveStyle({ backgroundColor: '#CD7F32' });
|
||||
});
|
||||
|
||||
it('should use default gray color when no color provided', () => {
|
||||
const { container } = render(<RankBadge level={1} name="Bronze" />);
|
||||
|
||||
const badge = container.querySelector('.rounded-full');
|
||||
expect(badge).toHaveStyle({ backgroundColor: '#6B7280' });
|
||||
});
|
||||
|
||||
it('should use default color when color is null', () => {
|
||||
const { container } = render(
|
||||
<RankBadge level={1} name="Bronze" color={null} />
|
||||
);
|
||||
|
||||
const badge = container.querySelector('.rounded-full');
|
||||
expect(badge).toHaveStyle({ backgroundColor: '#6B7280' });
|
||||
});
|
||||
|
||||
it('should apply different colors for different ranks', () => {
|
||||
const { container: bronzeContainer } = render(
|
||||
<RankBadge level={1} name="Bronze" color="#CD7F32" />
|
||||
);
|
||||
|
||||
const { container: goldContainer } = render(
|
||||
<RankBadge level={3} name="Gold" color="#FFD700" />
|
||||
);
|
||||
|
||||
const bronzeBadge = bronzeContainer.querySelector('.rounded-full');
|
||||
const goldBadge = goldContainer.querySelector('.rounded-full');
|
||||
|
||||
expect(bronzeBadge).toHaveStyle({ backgroundColor: '#CD7F32' });
|
||||
expect(goldBadge).toHaveStyle({ backgroundColor: '#FFD700' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('accessibility', () => {
|
||||
it('should have white text on colored background', () => {
|
||||
const { container } = render(<RankBadge level={1} name="Bronze" />);
|
||||
|
||||
const badge = container.querySelector('.rounded-full');
|
||||
expect(badge).toHaveClass('text-white');
|
||||
});
|
||||
|
||||
it('should have font-bold for badge text', () => {
|
||||
const { container } = render(<RankBadge level={1} name="Bronze" />);
|
||||
|
||||
const badge = container.querySelector('.rounded-full');
|
||||
expect(badge).toHaveClass('font-bold');
|
||||
});
|
||||
});
|
||||
|
||||
describe('layout', () => {
|
||||
it('should render as flex container', () => {
|
||||
const { container } = render(<RankBadge level={1} name="Bronze" />);
|
||||
|
||||
const wrapper = container.firstChild;
|
||||
expect(wrapper).toHaveClass('flex', 'items-center');
|
||||
});
|
||||
|
||||
it('should have margin between badge and text for non-small sizes', () => {
|
||||
const { container } = render(<RankBadge level={1} name="Bronze" size="md" />);
|
||||
|
||||
const textContainer = container.querySelector('.ml-2');
|
||||
expect(textContainer).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should handle long rank names', () => {
|
||||
render(<RankBadge level={5} name="Diamond Elite Master" />);
|
||||
|
||||
expect(screen.getByText('Diamond Elite Master')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should handle high level numbers', () => {
|
||||
render(<RankBadge level={99} name="Ultimate" size="lg" />);
|
||||
|
||||
expect(screen.getByText('99')).toBeInTheDocument();
|
||||
expect(screen.getByText(/Level 99/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should handle level 0', () => {
|
||||
render(<RankBadge level={0} name="Starter" />);
|
||||
|
||||
expect(screen.getByText('0')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should handle empty name with first letter fallback', () => {
|
||||
render(<RankBadge level={1} name="" showLevel={false} />);
|
||||
|
||||
// First letter of empty string is empty
|
||||
const { container } = render(<RankBadge level={1} name="" showLevel={false} />);
|
||||
const badge = container.querySelector('.rounded-full');
|
||||
expect(badge).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render correctly with default props', () => {
|
||||
render(<RankBadge level={1} name="Bronze" />);
|
||||
|
||||
// Default size is md, showLevel is true
|
||||
expect(screen.getByText('Bronze')).toBeInTheDocument();
|
||||
expect(screen.getByText('1')).toBeInTheDocument();
|
||||
expect(screen.getByText(/Level 1/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
270
src/__tests__/components/rbac/RoleForm.test.tsx
Normal file
270
src/__tests__/components/rbac/RoleForm.test.tsx
Normal file
@ -0,0 +1,270 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import type { ReactNode } from 'react';
|
||||
import { RoleForm } from '@/components/rbac/RoleForm';
|
||||
import type { Role, Permission } from '@/services/api';
|
||||
|
||||
// Mock react-router-dom navigate
|
||||
const mockNavigate = vi.fn();
|
||||
vi.mock('react-router-dom', async () => {
|
||||
const actual = await vi.importActual('react-router-dom');
|
||||
return {
|
||||
...actual,
|
||||
useNavigate: () => mockNavigate,
|
||||
};
|
||||
});
|
||||
|
||||
// Wrapper with router
|
||||
const renderWithRouter = (ui: ReactNode) => {
|
||||
return render(<BrowserRouter>{ui}</BrowserRouter>);
|
||||
};
|
||||
|
||||
describe('RoleForm', () => {
|
||||
const mockPermissions: Permission[] = [
|
||||
{ id: 'perm-1', name: 'Read Users', slug: 'users:read', description: null, category: 'users', created_at: '' },
|
||||
{ id: 'perm-2', name: 'Write Users', slug: 'users:write', description: null, category: 'users', created_at: '' },
|
||||
{ id: 'perm-3', name: 'Read Billing', slug: 'billing:read', description: null, category: 'billing', created_at: '' },
|
||||
{ id: 'perm-4', name: 'Write Billing', slug: 'billing:write', description: null, category: 'billing', created_at: '' },
|
||||
];
|
||||
|
||||
const mockRole: Role = {
|
||||
id: 'role-1',
|
||||
tenant_id: 'tenant-1',
|
||||
name: 'Admin',
|
||||
slug: 'admin',
|
||||
description: 'Administrator role',
|
||||
is_system: false,
|
||||
is_default: false,
|
||||
permissions: [mockPermissions[0], mockPermissions[1]],
|
||||
created_at: '2024-01-01',
|
||||
updated_at: '2024-01-01',
|
||||
};
|
||||
|
||||
const mockOnSubmit = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('rendering', () => {
|
||||
it('should render form with all fields', () => {
|
||||
renderWithRouter(
|
||||
<RoleForm permissions={mockPermissions} onSubmit={mockOnSubmit} />
|
||||
);
|
||||
|
||||
expect(screen.getByLabelText(/role name/i)).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/slug/i)).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/description/i)).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /create role/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render with pre-filled values when editing', () => {
|
||||
renderWithRouter(
|
||||
<RoleForm role={mockRole} permissions={mockPermissions} onSubmit={mockOnSubmit} />
|
||||
);
|
||||
|
||||
expect(screen.getByDisplayValue('Admin')).toBeInTheDocument();
|
||||
expect(screen.getByDisplayValue('admin')).toBeInTheDocument();
|
||||
expect(screen.getByDisplayValue('Administrator role')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /update role/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show permission count', () => {
|
||||
renderWithRouter(
|
||||
<RoleForm role={mockRole} permissions={mockPermissions} onSubmit={mockOnSubmit} />
|
||||
);
|
||||
|
||||
expect(screen.getByText(/2 of 4 selected/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should disable slug field when editing', () => {
|
||||
renderWithRouter(
|
||||
<RoleForm role={mockRole} permissions={mockPermissions} onSubmit={mockOnSubmit} />
|
||||
);
|
||||
|
||||
const slugInput = screen.getByDisplayValue('admin');
|
||||
expect(slugInput).toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('auto-slug generation', () => {
|
||||
it('should auto-generate slug from name when creating', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
renderWithRouter(
|
||||
<RoleForm permissions={mockPermissions} onSubmit={mockOnSubmit} />
|
||||
);
|
||||
|
||||
const nameInput = screen.getByLabelText(/role name/i);
|
||||
await user.type(nameInput, 'Sales Manager');
|
||||
|
||||
const slugInput = screen.getByPlaceholderText(/e\.g\., sales-manager/i);
|
||||
expect(slugInput).toHaveValue('sales-manager');
|
||||
});
|
||||
|
||||
it('should handle special characters in auto-slug', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
renderWithRouter(
|
||||
<RoleForm permissions={mockPermissions} onSubmit={mockOnSubmit} />
|
||||
);
|
||||
|
||||
const nameInput = screen.getByLabelText(/role name/i);
|
||||
await user.type(nameInput, 'Super Admin @2024!');
|
||||
|
||||
const slugInput = screen.getByPlaceholderText(/e\.g\., sales-manager/i);
|
||||
expect(slugInput).toHaveValue('super-admin-2024');
|
||||
});
|
||||
});
|
||||
|
||||
describe('form validation', () => {
|
||||
it('should disable submit button when name is empty', () => {
|
||||
renderWithRouter(
|
||||
<RoleForm permissions={mockPermissions} onSubmit={mockOnSubmit} />
|
||||
);
|
||||
|
||||
const submitButton = screen.getByRole('button', { name: /create role/i });
|
||||
expect(submitButton).toBeDisabled();
|
||||
});
|
||||
|
||||
it('should enable submit button when name is filled', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
renderWithRouter(
|
||||
<RoleForm permissions={mockPermissions} onSubmit={mockOnSubmit} />
|
||||
);
|
||||
|
||||
const nameInput = screen.getByLabelText(/role name/i);
|
||||
await user.type(nameInput, 'New Role');
|
||||
|
||||
const submitButton = screen.getByRole('button', { name: /create role/i });
|
||||
expect(submitButton).not.toBeDisabled();
|
||||
});
|
||||
|
||||
it('should disable submit button when loading', () => {
|
||||
renderWithRouter(
|
||||
<RoleForm
|
||||
role={mockRole}
|
||||
permissions={mockPermissions}
|
||||
onSubmit={mockOnSubmit}
|
||||
isLoading={true}
|
||||
/>
|
||||
);
|
||||
|
||||
const submitButton = screen.getByRole('button', { name: /saving/i });
|
||||
expect(submitButton).toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('form submission', () => {
|
||||
it('should call onSubmit with form data when creating', async () => {
|
||||
const user = userEvent.setup();
|
||||
mockOnSubmit.mockResolvedValue(undefined);
|
||||
|
||||
renderWithRouter(
|
||||
<RoleForm permissions={mockPermissions} onSubmit={mockOnSubmit} />
|
||||
);
|
||||
|
||||
const nameInput = screen.getByLabelText(/role name/i);
|
||||
await user.type(nameInput, 'New Role');
|
||||
|
||||
const descriptionInput = screen.getByLabelText(/description/i);
|
||||
await user.type(descriptionInput, 'A new test role');
|
||||
|
||||
const submitButton = screen.getByRole('button', { name: /create role/i });
|
||||
await user.click(submitButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockOnSubmit).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
name: 'New Role',
|
||||
slug: 'new-role',
|
||||
description: 'A new test role',
|
||||
permissions: [],
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('should call onSubmit with updated data when editing', async () => {
|
||||
const user = userEvent.setup();
|
||||
mockOnSubmit.mockResolvedValue(undefined);
|
||||
|
||||
renderWithRouter(
|
||||
<RoleForm role={mockRole} permissions={mockPermissions} onSubmit={mockOnSubmit} />
|
||||
);
|
||||
|
||||
const nameInput = screen.getByDisplayValue('Admin');
|
||||
await user.clear(nameInput);
|
||||
await user.type(nameInput, 'Super Admin');
|
||||
|
||||
const submitButton = screen.getByRole('button', { name: /update role/i });
|
||||
await user.click(submitButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockOnSubmit).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
name: 'Super Admin',
|
||||
permissions: ['users:read', 'users:write'],
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('permission selection', () => {
|
||||
it('should have Select All and Clear All buttons', () => {
|
||||
renderWithRouter(
|
||||
<RoleForm permissions={mockPermissions} onSubmit={mockOnSubmit} />
|
||||
);
|
||||
|
||||
expect(screen.getByRole('button', { name: /select all/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /clear all/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should select all permissions when clicking Select All', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
renderWithRouter(
|
||||
<RoleForm permissions={mockPermissions} onSubmit={mockOnSubmit} />
|
||||
);
|
||||
|
||||
const selectAllButton = screen.getByRole('button', { name: /select all/i });
|
||||
await user.click(selectAllButton);
|
||||
|
||||
expect(screen.getByText(/4 of 4 selected/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should clear all permissions when clicking Clear All', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
renderWithRouter(
|
||||
<RoleForm role={mockRole} permissions={mockPermissions} onSubmit={mockOnSubmit} />
|
||||
);
|
||||
|
||||
expect(screen.getByText(/2 of 4 selected/i)).toBeInTheDocument();
|
||||
|
||||
const clearAllButton = screen.getByRole('button', { name: /clear all/i });
|
||||
await user.click(clearAllButton);
|
||||
|
||||
expect(screen.getByText(/0 of 4 selected/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('navigation', () => {
|
||||
it('should navigate to roles list when clicking Cancel', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
renderWithRouter(
|
||||
<RoleForm permissions={mockPermissions} onSubmit={mockOnSubmit} />
|
||||
);
|
||||
|
||||
const cancelButton = screen.getByRole('button', { name: /cancel/i });
|
||||
await user.click(cancelButton);
|
||||
|
||||
expect(mockNavigate).toHaveBeenCalledWith('/dashboard/rbac/roles');
|
||||
});
|
||||
});
|
||||
});
|
||||
390
src/__tests__/hooks/useAuth.test.tsx
Normal file
390
src/__tests__/hooks/useAuth.test.tsx
Normal file
@ -0,0 +1,390 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { renderHook, waitFor } from '@testing-library/react';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import type { ReactNode } from 'react';
|
||||
import {
|
||||
useCurrentUser,
|
||||
useLogin,
|
||||
useLogout,
|
||||
useRegister,
|
||||
useRequestPasswordReset,
|
||||
useResetPassword,
|
||||
useChangePassword,
|
||||
useVerifyEmail,
|
||||
authKeys,
|
||||
} from '@/hooks/useAuth';
|
||||
import { authApi } from '@/services/api';
|
||||
import { useAuthStore } from '@/stores';
|
||||
|
||||
// Mock the API module
|
||||
vi.mock('@/services/api', () => ({
|
||||
authApi: {
|
||||
login: vi.fn(),
|
||||
register: vi.fn(),
|
||||
logout: vi.fn(),
|
||||
requestPasswordReset: vi.fn(),
|
||||
resetPassword: vi.fn(),
|
||||
changePassword: vi.fn(),
|
||||
verifyEmail: vi.fn(),
|
||||
me: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock react-router-dom
|
||||
vi.mock('react-router-dom', () => ({
|
||||
useNavigate: () => vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock react-hot-toast
|
||||
vi.mock('react-hot-toast', () => ({
|
||||
default: {
|
||||
success: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// Create wrapper for react-query
|
||||
const createWrapper = () => {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
});
|
||||
return ({ children }: { children: ReactNode }) => (
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
);
|
||||
};
|
||||
|
||||
describe('useAuth hooks', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// Reset auth store
|
||||
useAuthStore.getState().logout();
|
||||
});
|
||||
|
||||
describe('authKeys', () => {
|
||||
it('should have correct query key structure', () => {
|
||||
expect(authKeys.all).toEqual(['auth']);
|
||||
expect(authKeys.me()).toEqual(['auth', 'me']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('useCurrentUser', () => {
|
||||
it('should not fetch when not authenticated', async () => {
|
||||
const { result } = renderHook(() => useCurrentUser(), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
expect(result.current.isFetching).toBe(false);
|
||||
expect(authApi.me).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should fetch user data when authenticated', async () => {
|
||||
const mockUser = {
|
||||
id: 'user-1',
|
||||
email: 'test@example.com',
|
||||
first_name: 'John',
|
||||
last_name: 'Doe',
|
||||
tenant_id: 'tenant-1',
|
||||
status: 'active',
|
||||
email_verified: true,
|
||||
created_at: '2024-01-01',
|
||||
};
|
||||
|
||||
vi.mocked(authApi.me).mockResolvedValue(mockUser);
|
||||
|
||||
// Set authenticated state
|
||||
useAuthStore.getState().login(
|
||||
{
|
||||
id: 'user-1',
|
||||
email: 'test@example.com',
|
||||
first_name: 'John',
|
||||
last_name: 'Doe',
|
||||
role: 'user',
|
||||
tenant_id: 'tenant-1',
|
||||
},
|
||||
'access-token',
|
||||
'refresh-token'
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useCurrentUser(), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isSuccess).toBe(true);
|
||||
});
|
||||
|
||||
expect(authApi.me).toHaveBeenCalled();
|
||||
expect(result.current.data).toEqual(mockUser);
|
||||
});
|
||||
});
|
||||
|
||||
describe('useLogin', () => {
|
||||
it('should return mutation function', () => {
|
||||
const { result } = renderHook(() => useLogin(), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
expect(result.current.mutate).toBeDefined();
|
||||
expect(result.current.mutateAsync).toBeDefined();
|
||||
});
|
||||
|
||||
it('should handle successful login', async () => {
|
||||
const mockResponse = {
|
||||
user: {
|
||||
id: 'user-1',
|
||||
email: 'test@example.com',
|
||||
first_name: 'John',
|
||||
last_name: 'Doe',
|
||||
tenant_id: 'tenant-1',
|
||||
status: 'active',
|
||||
email_verified: true,
|
||||
created_at: '2024-01-01',
|
||||
},
|
||||
accessToken: 'new-access-token',
|
||||
refreshToken: 'new-refresh-token',
|
||||
};
|
||||
|
||||
vi.mocked(authApi.login).mockResolvedValue(mockResponse);
|
||||
|
||||
const { result } = renderHook(() => useLogin(), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
result.current.mutate({ email: 'test@example.com', password: 'password123' });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isSuccess).toBe(true);
|
||||
});
|
||||
|
||||
expect(authApi.login).toHaveBeenCalledWith('test@example.com', 'password123');
|
||||
expect(useAuthStore.getState().isAuthenticated).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle login error', async () => {
|
||||
const mockError = {
|
||||
response: {
|
||||
data: { message: 'Invalid credentials' },
|
||||
},
|
||||
};
|
||||
|
||||
vi.mocked(authApi.login).mockRejectedValue(mockError);
|
||||
|
||||
const { result } = renderHook(() => useLogin(), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
result.current.mutate({ email: 'test@example.com', password: 'wrong' });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('useLogout', () => {
|
||||
it('should return mutation function', () => {
|
||||
const { result } = renderHook(() => useLogout(), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
expect(result.current.mutate).toBeDefined();
|
||||
});
|
||||
|
||||
it('should clear auth state on logout', async () => {
|
||||
// First set authenticated state
|
||||
useAuthStore.getState().login(
|
||||
{
|
||||
id: 'user-1',
|
||||
email: 'test@example.com',
|
||||
first_name: 'John',
|
||||
last_name: 'Doe',
|
||||
role: 'user',
|
||||
tenant_id: 'tenant-1',
|
||||
},
|
||||
'access-token',
|
||||
'refresh-token'
|
||||
);
|
||||
|
||||
vi.mocked(authApi.logout).mockResolvedValue({ message: 'Logged out' });
|
||||
|
||||
const { result } = renderHook(() => useLogout(), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
expect(useAuthStore.getState().isAuthenticated).toBe(true);
|
||||
|
||||
result.current.mutate();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isSuccess).toBe(true);
|
||||
});
|
||||
|
||||
expect(useAuthStore.getState().isAuthenticated).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('useRegister', () => {
|
||||
it('should return mutation function', () => {
|
||||
const { result } = renderHook(() => useRegister(), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
expect(result.current.mutate).toBeDefined();
|
||||
});
|
||||
|
||||
it('should handle successful registration', async () => {
|
||||
const mockResponse = {
|
||||
user: {
|
||||
id: 'user-1',
|
||||
email: 'new@example.com',
|
||||
first_name: 'New',
|
||||
last_name: 'User',
|
||||
tenant_id: 'tenant-1',
|
||||
status: 'active',
|
||||
email_verified: false,
|
||||
created_at: '2024-01-01',
|
||||
},
|
||||
accessToken: 'access-token',
|
||||
refreshToken: 'refresh-token',
|
||||
};
|
||||
|
||||
vi.mocked(authApi.register).mockResolvedValue(mockResponse);
|
||||
|
||||
const { result } = renderHook(() => useRegister(), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
result.current.mutate({
|
||||
email: 'new@example.com',
|
||||
password: 'password123',
|
||||
first_name: 'New',
|
||||
last_name: 'User',
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isSuccess).toBe(true);
|
||||
});
|
||||
|
||||
expect(authApi.register).toHaveBeenCalled();
|
||||
expect(useAuthStore.getState().isAuthenticated).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('useRequestPasswordReset', () => {
|
||||
it('should return mutation function', () => {
|
||||
const { result } = renderHook(() => useRequestPasswordReset(), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
expect(result.current.mutate).toBeDefined();
|
||||
});
|
||||
|
||||
it('should handle password reset request', async () => {
|
||||
vi.mocked(authApi.requestPasswordReset).mockResolvedValue({
|
||||
message: 'Reset email sent',
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useRequestPasswordReset(), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
result.current.mutate('test@example.com');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isSuccess).toBe(true);
|
||||
});
|
||||
|
||||
expect(authApi.requestPasswordReset).toHaveBeenCalledWith('test@example.com');
|
||||
});
|
||||
});
|
||||
|
||||
describe('useResetPassword', () => {
|
||||
it('should return mutation function', () => {
|
||||
const { result } = renderHook(() => useResetPassword(), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
expect(result.current.mutate).toBeDefined();
|
||||
});
|
||||
|
||||
it('should handle password reset', async () => {
|
||||
vi.mocked(authApi.resetPassword).mockResolvedValue({
|
||||
message: 'Password reset successfully',
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useResetPassword(), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
result.current.mutate({ token: 'reset-token', password: 'newpassword123' });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isSuccess).toBe(true);
|
||||
});
|
||||
|
||||
expect(authApi.resetPassword).toHaveBeenCalledWith('reset-token', 'newpassword123');
|
||||
});
|
||||
});
|
||||
|
||||
describe('useChangePassword', () => {
|
||||
it('should return mutation function', () => {
|
||||
const { result } = renderHook(() => useChangePassword(), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
expect(result.current.mutate).toBeDefined();
|
||||
});
|
||||
|
||||
it('should handle password change', async () => {
|
||||
vi.mocked(authApi.changePassword).mockResolvedValue({
|
||||
message: 'Password changed',
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useChangePassword(), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
result.current.mutate({
|
||||
currentPassword: 'oldpass',
|
||||
newPassword: 'newpass',
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isSuccess).toBe(true);
|
||||
});
|
||||
|
||||
expect(authApi.changePassword).toHaveBeenCalledWith('oldpass', 'newpass');
|
||||
});
|
||||
});
|
||||
|
||||
describe('useVerifyEmail', () => {
|
||||
it('should return mutation function', () => {
|
||||
const { result } = renderHook(() => useVerifyEmail(), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
expect(result.current.mutate).toBeDefined();
|
||||
});
|
||||
|
||||
it('should handle email verification', async () => {
|
||||
vi.mocked(authApi.verifyEmail).mockResolvedValue({
|
||||
message: 'Email verified',
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useVerifyEmail(), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
result.current.mutate('verification-token');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isSuccess).toBe(true);
|
||||
});
|
||||
|
||||
expect(authApi.verifyEmail).toHaveBeenCalledWith('verification-token');
|
||||
});
|
||||
});
|
||||
});
|
||||
424
src/__tests__/hooks/useGoals.test.tsx
Normal file
424
src/__tests__/hooks/useGoals.test.tsx
Normal file
@ -0,0 +1,424 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { renderHook, waitFor } from '@testing-library/react';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import type { ReactNode } from 'react';
|
||||
import {
|
||||
useGoalDefinitions,
|
||||
useGoalDefinition,
|
||||
useCreateGoalDefinition,
|
||||
useUpdateGoalDefinition,
|
||||
useDeleteGoalDefinition,
|
||||
useGoalAssignments,
|
||||
useGoalAssignment,
|
||||
useCreateGoalAssignment,
|
||||
useUpdateGoalProgress,
|
||||
useMyGoals,
|
||||
useMyGoalsSummary,
|
||||
useGoalCompletionReport,
|
||||
useGoalUserReport,
|
||||
} from '@/hooks/useGoals';
|
||||
import { definitionsApi, assignmentsApi } from '@/services/goals';
|
||||
|
||||
// Mock the API modules
|
||||
vi.mock('@/services/goals', () => ({
|
||||
definitionsApi: {
|
||||
list: vi.fn(),
|
||||
get: vi.fn(),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
updateStatus: vi.fn(),
|
||||
activate: vi.fn(),
|
||||
duplicate: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
},
|
||||
assignmentsApi: {
|
||||
list: vi.fn(),
|
||||
listByGoal: vi.fn(),
|
||||
get: vi.fn(),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
updateStatus: vi.fn(),
|
||||
updateProgress: vi.fn(),
|
||||
getHistory: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
getMyGoals: vi.fn(),
|
||||
getMyGoalsSummary: vi.fn(),
|
||||
updateMyProgress: vi.fn(),
|
||||
getCompletionReport: vi.fn(),
|
||||
getUserReport: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// Create wrapper for react-query
|
||||
const createWrapper = () => {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
});
|
||||
return ({ children }: { children: ReactNode }) => (
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
);
|
||||
};
|
||||
|
||||
describe('useGoals hooks', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('useGoalDefinitions', () => {
|
||||
it('should fetch all goal definitions', async () => {
|
||||
const mockDefinitions = [
|
||||
{
|
||||
id: 'goal-1',
|
||||
name: 'Monthly Sales Target',
|
||||
targetValue: 10000,
|
||||
unit: 'USD',
|
||||
status: 'active',
|
||||
},
|
||||
{
|
||||
id: 'goal-2',
|
||||
name: 'Customer Acquisition',
|
||||
targetValue: 50,
|
||||
unit: 'customers',
|
||||
status: 'draft',
|
||||
},
|
||||
];
|
||||
|
||||
vi.mocked(definitionsApi.list).mockResolvedValue(mockDefinitions);
|
||||
|
||||
const { result } = renderHook(() => useGoalDefinitions(), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isSuccess).toBe(true);
|
||||
});
|
||||
|
||||
expect(definitionsApi.list).toHaveBeenCalled();
|
||||
expect(result.current.data).toEqual(mockDefinitions);
|
||||
});
|
||||
|
||||
it('should fetch with filters', async () => {
|
||||
const mockDefinitions = [{ id: 'goal-1', name: 'Active Goal', status: 'active' }];
|
||||
vi.mocked(definitionsApi.list).mockResolvedValue(mockDefinitions);
|
||||
|
||||
const filters = { status: 'active' as const };
|
||||
const { result } = renderHook(() => useGoalDefinitions(filters), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isSuccess).toBe(true);
|
||||
});
|
||||
|
||||
expect(definitionsApi.list).toHaveBeenCalledWith(filters);
|
||||
});
|
||||
});
|
||||
|
||||
describe('useGoalDefinition', () => {
|
||||
it('should fetch single goal definition', async () => {
|
||||
const mockDefinition = {
|
||||
id: 'goal-1',
|
||||
name: 'Monthly Sales Target',
|
||||
targetValue: 10000,
|
||||
unit: 'USD',
|
||||
};
|
||||
|
||||
vi.mocked(definitionsApi.get).mockResolvedValue(mockDefinition);
|
||||
|
||||
const { result } = renderHook(() => useGoalDefinition('goal-1'), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isSuccess).toBe(true);
|
||||
});
|
||||
|
||||
expect(definitionsApi.get).toHaveBeenCalledWith('goal-1');
|
||||
expect(result.current.data).toEqual(mockDefinition);
|
||||
});
|
||||
|
||||
it('should not fetch when id is empty', () => {
|
||||
const { result } = renderHook(() => useGoalDefinition(''), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
expect(result.current.isFetching).toBe(false);
|
||||
expect(definitionsApi.get).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('useCreateGoalDefinition', () => {
|
||||
it('should create a new goal definition', async () => {
|
||||
const newGoal = {
|
||||
id: 'goal-3',
|
||||
name: 'New Goal',
|
||||
targetValue: 5000,
|
||||
unit: 'units',
|
||||
};
|
||||
|
||||
vi.mocked(definitionsApi.create).mockResolvedValue(newGoal);
|
||||
|
||||
const { result } = renderHook(() => useCreateGoalDefinition(), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
result.current.mutate({
|
||||
name: 'New Goal',
|
||||
target_value: 5000,
|
||||
metric_type: 'sales',
|
||||
period: 'monthly',
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isSuccess).toBe(true);
|
||||
});
|
||||
|
||||
expect(definitionsApi.create).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('useUpdateGoalDefinition', () => {
|
||||
it('should update goal definition', async () => {
|
||||
const updatedGoal = {
|
||||
id: 'goal-1',
|
||||
name: 'Updated Goal',
|
||||
targetValue: 15000,
|
||||
};
|
||||
|
||||
vi.mocked(definitionsApi.update).mockResolvedValue(updatedGoal);
|
||||
|
||||
const { result } = renderHook(() => useUpdateGoalDefinition(), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
result.current.mutate({
|
||||
id: 'goal-1',
|
||||
data: { name: 'Updated Goal', target_value: 15000 },
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isSuccess).toBe(true);
|
||||
});
|
||||
|
||||
expect(definitionsApi.update).toHaveBeenCalledWith('goal-1', {
|
||||
name: 'Updated Goal',
|
||||
target_value: 15000,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('useDeleteGoalDefinition', () => {
|
||||
it('should delete goal definition', async () => {
|
||||
vi.mocked(definitionsApi.delete).mockResolvedValue(undefined);
|
||||
|
||||
const { result } = renderHook(() => useDeleteGoalDefinition(), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
result.current.mutate('goal-1');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isSuccess).toBe(true);
|
||||
});
|
||||
|
||||
expect(definitionsApi.delete).toHaveBeenCalledWith('goal-1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('useGoalAssignments', () => {
|
||||
it('should fetch goal assignments', async () => {
|
||||
const mockAssignments = [
|
||||
{ id: 'assign-1', definitionId: 'goal-1', userId: 'user-1', progress: 50 },
|
||||
{ id: 'assign-2', definitionId: 'goal-1', userId: 'user-2', progress: 75 },
|
||||
];
|
||||
|
||||
vi.mocked(assignmentsApi.list).mockResolvedValue(mockAssignments);
|
||||
|
||||
const { result } = renderHook(() => useGoalAssignments(), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isSuccess).toBe(true);
|
||||
});
|
||||
|
||||
expect(assignmentsApi.list).toHaveBeenCalled();
|
||||
expect(result.current.data).toEqual(mockAssignments);
|
||||
});
|
||||
});
|
||||
|
||||
describe('useGoalAssignment', () => {
|
||||
it('should fetch single assignment', async () => {
|
||||
const mockAssignment = {
|
||||
id: 'assign-1',
|
||||
definitionId: 'goal-1',
|
||||
userId: 'user-1',
|
||||
progress: 50,
|
||||
};
|
||||
|
||||
vi.mocked(assignmentsApi.get).mockResolvedValue(mockAssignment);
|
||||
|
||||
const { result } = renderHook(() => useGoalAssignment('assign-1'), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isSuccess).toBe(true);
|
||||
});
|
||||
|
||||
expect(assignmentsApi.get).toHaveBeenCalledWith('assign-1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('useCreateGoalAssignment', () => {
|
||||
it('should create goal assignment', async () => {
|
||||
const newAssignment = {
|
||||
id: 'assign-3',
|
||||
definitionId: 'goal-1',
|
||||
userId: 'user-3',
|
||||
progress: 0,
|
||||
};
|
||||
|
||||
vi.mocked(assignmentsApi.create).mockResolvedValue(newAssignment);
|
||||
|
||||
const { result } = renderHook(() => useCreateGoalAssignment(), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
result.current.mutate({
|
||||
definition_id: 'goal-1',
|
||||
user_id: 'user-3',
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isSuccess).toBe(true);
|
||||
});
|
||||
|
||||
expect(assignmentsApi.create).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('useUpdateGoalProgress', () => {
|
||||
it('should update goal progress', async () => {
|
||||
const updatedAssignment = {
|
||||
id: 'assign-1',
|
||||
progress: 75,
|
||||
currentValue: 7500,
|
||||
};
|
||||
|
||||
vi.mocked(assignmentsApi.updateProgress).mockResolvedValue(updatedAssignment);
|
||||
|
||||
const { result } = renderHook(() => useUpdateGoalProgress(), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
result.current.mutate({
|
||||
id: 'assign-1',
|
||||
data: { current_value: 7500, notes: 'Good progress' },
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isSuccess).toBe(true);
|
||||
});
|
||||
|
||||
expect(assignmentsApi.updateProgress).toHaveBeenCalledWith('assign-1', {
|
||||
current_value: 7500,
|
||||
notes: 'Good progress',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('useMyGoals', () => {
|
||||
it('should fetch current user goals', async () => {
|
||||
const mockGoals = [
|
||||
{ id: 'assign-1', definitionId: 'goal-1', progress: 50 },
|
||||
{ id: 'assign-2', definitionId: 'goal-2', progress: 80 },
|
||||
];
|
||||
|
||||
vi.mocked(assignmentsApi.getMyGoals).mockResolvedValue(mockGoals);
|
||||
|
||||
const { result } = renderHook(() => useMyGoals(), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isSuccess).toBe(true);
|
||||
});
|
||||
|
||||
expect(assignmentsApi.getMyGoals).toHaveBeenCalled();
|
||||
expect(result.current.data).toEqual(mockGoals);
|
||||
});
|
||||
});
|
||||
|
||||
describe('useMyGoalsSummary', () => {
|
||||
it('should fetch current user goals summary', async () => {
|
||||
const mockSummary = {
|
||||
total: 5,
|
||||
completed: 2,
|
||||
in_progress: 2,
|
||||
not_started: 1,
|
||||
average_progress: 45,
|
||||
};
|
||||
|
||||
vi.mocked(assignmentsApi.getMyGoalsSummary).mockResolvedValue(mockSummary);
|
||||
|
||||
const { result } = renderHook(() => useMyGoalsSummary(), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isSuccess).toBe(true);
|
||||
});
|
||||
|
||||
expect(assignmentsApi.getMyGoalsSummary).toHaveBeenCalled();
|
||||
expect(result.current.data).toEqual(mockSummary);
|
||||
});
|
||||
});
|
||||
|
||||
describe('useGoalCompletionReport', () => {
|
||||
it('should fetch completion report', async () => {
|
||||
const mockReport = {
|
||||
totalGoals: 10,
|
||||
completedGoals: 6,
|
||||
completionRate: 60,
|
||||
byPeriod: [],
|
||||
};
|
||||
|
||||
vi.mocked(assignmentsApi.getCompletionReport).mockResolvedValue(mockReport);
|
||||
|
||||
const { result } = renderHook(() => useGoalCompletionReport('2024-01-01', '2024-12-31'), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isSuccess).toBe(true);
|
||||
});
|
||||
|
||||
expect(assignmentsApi.getCompletionReport).toHaveBeenCalledWith('2024-01-01', '2024-12-31');
|
||||
});
|
||||
});
|
||||
|
||||
describe('useGoalUserReport', () => {
|
||||
it('should fetch user report', async () => {
|
||||
const mockReport = [
|
||||
{ userId: 'user-1', completedGoals: 5, totalGoals: 8 },
|
||||
{ userId: 'user-2', completedGoals: 3, totalGoals: 5 },
|
||||
];
|
||||
|
||||
vi.mocked(assignmentsApi.getUserReport).mockResolvedValue(mockReport);
|
||||
|
||||
const { result } = renderHook(() => useGoalUserReport(), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isSuccess).toBe(true);
|
||||
});
|
||||
|
||||
expect(assignmentsApi.getUserReport).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
500
src/__tests__/hooks/useMlm.test.tsx
Normal file
500
src/__tests__/hooks/useMlm.test.tsx
Normal file
@ -0,0 +1,500 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { renderHook, waitFor } from '@testing-library/react';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import type { ReactNode } from 'react';
|
||||
import {
|
||||
useStructures,
|
||||
useStructure,
|
||||
useCreateStructure,
|
||||
useUpdateStructure,
|
||||
useDeleteStructure,
|
||||
useRanks,
|
||||
useRank,
|
||||
useCreateRank,
|
||||
useNodes,
|
||||
useNode,
|
||||
useCreateNode,
|
||||
useNodeDownline,
|
||||
useMyDashboard,
|
||||
useMyNetwork,
|
||||
useMyEarnings,
|
||||
useMyRank,
|
||||
useMLMCommissions,
|
||||
useCalculateCommissions,
|
||||
} from '@/hooks/useMlm';
|
||||
import { structuresApi, ranksApi, nodesApi, commissionsApi } from '@/services/mlm';
|
||||
|
||||
// Mock the API modules
|
||||
vi.mock('@/services/mlm', () => ({
|
||||
structuresApi: {
|
||||
list: vi.fn(),
|
||||
get: vi.fn(),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
},
|
||||
ranksApi: {
|
||||
list: vi.fn(),
|
||||
get: vi.fn(),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
evaluate: vi.fn(),
|
||||
},
|
||||
nodesApi: {
|
||||
list: vi.fn(),
|
||||
get: vi.fn(),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
updateStatus: vi.fn(),
|
||||
getDownline: vi.fn(),
|
||||
getUpline: vi.fn(),
|
||||
getTree: vi.fn(),
|
||||
getMyDashboard: vi.fn(),
|
||||
getMyNetwork: vi.fn(),
|
||||
getMyEarnings: vi.fn(),
|
||||
getMyRank: vi.fn(),
|
||||
generateInviteLink: vi.fn(),
|
||||
},
|
||||
commissionsApi: {
|
||||
list: vi.fn(),
|
||||
get: vi.fn(),
|
||||
calculate: vi.fn(),
|
||||
updateStatus: vi.fn(),
|
||||
getByLevel: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// Create wrapper for react-query
|
||||
const createWrapper = () => {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
});
|
||||
return ({ children }: { children: ReactNode }) => (
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
);
|
||||
};
|
||||
|
||||
describe('useMlm hooks', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
// ==================== Structures ====================
|
||||
|
||||
describe('useStructures', () => {
|
||||
it('should fetch all structures', async () => {
|
||||
const mockStructures = [
|
||||
{ id: 'struct-1', name: 'Binary Structure', type: 'binary', maxWidth: 2 },
|
||||
{ id: 'struct-2', name: 'Unilevel Structure', type: 'unilevel', maxWidth: null },
|
||||
];
|
||||
|
||||
vi.mocked(structuresApi.list).mockResolvedValue(mockStructures);
|
||||
|
||||
const { result } = renderHook(() => useStructures(), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isSuccess).toBe(true);
|
||||
});
|
||||
|
||||
expect(structuresApi.list).toHaveBeenCalled();
|
||||
expect(result.current.data).toEqual(mockStructures);
|
||||
});
|
||||
});
|
||||
|
||||
describe('useStructure', () => {
|
||||
it('should fetch single structure', async () => {
|
||||
const mockStructure = {
|
||||
id: 'struct-1',
|
||||
name: 'Binary Structure',
|
||||
type: 'binary',
|
||||
maxWidth: 2,
|
||||
};
|
||||
|
||||
vi.mocked(structuresApi.get).mockResolvedValue(mockStructure);
|
||||
|
||||
const { result } = renderHook(() => useStructure('struct-1'), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isSuccess).toBe(true);
|
||||
});
|
||||
|
||||
expect(structuresApi.get).toHaveBeenCalledWith('struct-1');
|
||||
});
|
||||
|
||||
it('should not fetch when id is empty', () => {
|
||||
const { result } = renderHook(() => useStructure(''), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
expect(result.current.isFetching).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('useCreateStructure', () => {
|
||||
it('should create new structure', async () => {
|
||||
const newStructure = { id: 'struct-3', name: 'Matrix', type: 'matrix' };
|
||||
vi.mocked(structuresApi.create).mockResolvedValue(newStructure);
|
||||
|
||||
const { result } = renderHook(() => useCreateStructure(), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
result.current.mutate({
|
||||
name: 'Matrix',
|
||||
type: 'matrix',
|
||||
max_width: 3,
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isSuccess).toBe(true);
|
||||
});
|
||||
|
||||
expect(structuresApi.create).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('useUpdateStructure', () => {
|
||||
it('should update structure', async () => {
|
||||
const updatedStructure = { id: 'struct-1', name: 'Updated Binary' };
|
||||
vi.mocked(structuresApi.update).mockResolvedValue(updatedStructure);
|
||||
|
||||
const { result } = renderHook(() => useUpdateStructure(), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
result.current.mutate({
|
||||
id: 'struct-1',
|
||||
data: { name: 'Updated Binary' },
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isSuccess).toBe(true);
|
||||
});
|
||||
|
||||
expect(structuresApi.update).toHaveBeenCalledWith('struct-1', { name: 'Updated Binary' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('useDeleteStructure', () => {
|
||||
it('should delete structure', async () => {
|
||||
vi.mocked(structuresApi.delete).mockResolvedValue(undefined);
|
||||
|
||||
const { result } = renderHook(() => useDeleteStructure(), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
result.current.mutate('struct-1');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isSuccess).toBe(true);
|
||||
});
|
||||
|
||||
expect(structuresApi.delete).toHaveBeenCalledWith('struct-1');
|
||||
});
|
||||
});
|
||||
|
||||
// ==================== Ranks ====================
|
||||
|
||||
describe('useRanks', () => {
|
||||
it('should fetch all ranks', async () => {
|
||||
const mockRanks = [
|
||||
{ id: 'rank-1', name: 'Bronze', level: 1, minVolume: 0 },
|
||||
{ id: 'rank-2', name: 'Silver', level: 2, minVolume: 1000 },
|
||||
{ id: 'rank-3', name: 'Gold', level: 3, minVolume: 5000 },
|
||||
];
|
||||
|
||||
vi.mocked(ranksApi.list).mockResolvedValue(mockRanks);
|
||||
|
||||
const { result } = renderHook(() => useRanks(), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isSuccess).toBe(true);
|
||||
});
|
||||
|
||||
expect(ranksApi.list).toHaveBeenCalled();
|
||||
expect(result.current.data).toEqual(mockRanks);
|
||||
});
|
||||
});
|
||||
|
||||
describe('useRank', () => {
|
||||
it('should fetch single rank', async () => {
|
||||
const mockRank = { id: 'rank-1', name: 'Bronze', level: 1 };
|
||||
vi.mocked(ranksApi.get).mockResolvedValue(mockRank);
|
||||
|
||||
const { result } = renderHook(() => useRank('rank-1'), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isSuccess).toBe(true);
|
||||
});
|
||||
|
||||
expect(ranksApi.get).toHaveBeenCalledWith('rank-1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('useCreateRank', () => {
|
||||
it('should create new rank', async () => {
|
||||
const newRank = { id: 'rank-4', name: 'Platinum', level: 4 };
|
||||
vi.mocked(ranksApi.create).mockResolvedValue(newRank);
|
||||
|
||||
const { result } = renderHook(() => useCreateRank(), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
result.current.mutate({
|
||||
name: 'Platinum',
|
||||
level: 4,
|
||||
structure_id: 'struct-1',
|
||||
min_personal_volume: 10000,
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isSuccess).toBe(true);
|
||||
});
|
||||
|
||||
expect(ranksApi.create).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ==================== Nodes ====================
|
||||
|
||||
describe('useNodes', () => {
|
||||
it('should fetch all nodes', async () => {
|
||||
const mockNodes = [
|
||||
{ id: 'node-1', userId: 'user-1', rankId: 'rank-1', status: 'active' },
|
||||
{ id: 'node-2', userId: 'user-2', rankId: 'rank-2', status: 'active' },
|
||||
];
|
||||
|
||||
vi.mocked(nodesApi.list).mockResolvedValue(mockNodes);
|
||||
|
||||
const { result } = renderHook(() => useNodes(), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isSuccess).toBe(true);
|
||||
});
|
||||
|
||||
expect(nodesApi.list).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('useNode', () => {
|
||||
it('should fetch single node', async () => {
|
||||
const mockNode = { id: 'node-1', userId: 'user-1', status: 'active' };
|
||||
vi.mocked(nodesApi.get).mockResolvedValue(mockNode);
|
||||
|
||||
const { result } = renderHook(() => useNode('node-1'), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isSuccess).toBe(true);
|
||||
});
|
||||
|
||||
expect(nodesApi.get).toHaveBeenCalledWith('node-1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('useCreateNode', () => {
|
||||
it('should create new node', async () => {
|
||||
const newNode = { id: 'node-3', userId: 'user-3', status: 'pending' };
|
||||
vi.mocked(nodesApi.create).mockResolvedValue(newNode);
|
||||
|
||||
const { result } = renderHook(() => useCreateNode(), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
result.current.mutate({
|
||||
user_id: 'user-3',
|
||||
structure_id: 'struct-1',
|
||||
sponsor_id: 'node-1',
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isSuccess).toBe(true);
|
||||
});
|
||||
|
||||
expect(nodesApi.create).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('useNodeDownline', () => {
|
||||
it('should fetch node downline', async () => {
|
||||
const mockDownline = [
|
||||
{ id: 'node-2', depth: 1, userId: 'user-2' },
|
||||
{ id: 'node-3', depth: 2, userId: 'user-3' },
|
||||
];
|
||||
|
||||
vi.mocked(nodesApi.getDownline).mockResolvedValue(mockDownline);
|
||||
|
||||
const { result } = renderHook(() => useNodeDownline('node-1', 3), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isSuccess).toBe(true);
|
||||
});
|
||||
|
||||
expect(nodesApi.getDownline).toHaveBeenCalledWith('node-1', 3);
|
||||
});
|
||||
});
|
||||
|
||||
// ==================== My Network ====================
|
||||
|
||||
describe('useMyDashboard', () => {
|
||||
it('should fetch my dashboard data', async () => {
|
||||
const mockDashboard = {
|
||||
totalDownline: 25,
|
||||
activeDownline: 20,
|
||||
personalVolume: 5000,
|
||||
groupVolume: 50000,
|
||||
currentRank: 'Gold',
|
||||
pendingCommissions: 1500,
|
||||
};
|
||||
|
||||
vi.mocked(nodesApi.getMyDashboard).mockResolvedValue(mockDashboard);
|
||||
|
||||
const { result } = renderHook(() => useMyDashboard(), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isSuccess).toBe(true);
|
||||
});
|
||||
|
||||
expect(nodesApi.getMyDashboard).toHaveBeenCalled();
|
||||
expect(result.current.data).toEqual(mockDashboard);
|
||||
});
|
||||
});
|
||||
|
||||
describe('useMyNetwork', () => {
|
||||
it('should fetch my network tree', async () => {
|
||||
const mockNetwork = {
|
||||
node: { id: 'node-1', userId: 'user-1' },
|
||||
children: [
|
||||
{ id: 'node-2', userId: 'user-2' },
|
||||
],
|
||||
};
|
||||
|
||||
vi.mocked(nodesApi.getMyNetwork).mockResolvedValue(mockNetwork);
|
||||
|
||||
const { result } = renderHook(() => useMyNetwork(3), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isSuccess).toBe(true);
|
||||
});
|
||||
|
||||
expect(nodesApi.getMyNetwork).toHaveBeenCalledWith(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('useMyEarnings', () => {
|
||||
it('should fetch my earnings', async () => {
|
||||
const mockEarnings = {
|
||||
totalEarnings: 10000,
|
||||
pendingPayouts: 2500,
|
||||
paidOut: 7500,
|
||||
byMonth: [],
|
||||
};
|
||||
|
||||
vi.mocked(nodesApi.getMyEarnings).mockResolvedValue(mockEarnings);
|
||||
|
||||
const { result } = renderHook(() => useMyEarnings(), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isSuccess).toBe(true);
|
||||
});
|
||||
|
||||
expect(nodesApi.getMyEarnings).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('useMyRank', () => {
|
||||
it('should fetch my current rank', async () => {
|
||||
const mockRank = {
|
||||
current: { id: 'rank-3', name: 'Gold', level: 3 },
|
||||
next: { id: 'rank-4', name: 'Platinum', level: 4 },
|
||||
progressToNext: 75,
|
||||
};
|
||||
|
||||
vi.mocked(nodesApi.getMyRank).mockResolvedValue(mockRank);
|
||||
|
||||
const { result } = renderHook(() => useMyRank(), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isSuccess).toBe(true);
|
||||
});
|
||||
|
||||
expect(nodesApi.getMyRank).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ==================== Commissions ====================
|
||||
|
||||
describe('useMLMCommissions', () => {
|
||||
it('should fetch commissions list', async () => {
|
||||
const mockCommissions = [
|
||||
{ id: 'comm-1', nodeId: 'node-1', amount: 500, status: 'pending' },
|
||||
{ id: 'comm-2', nodeId: 'node-1', amount: 750, status: 'paid' },
|
||||
];
|
||||
|
||||
vi.mocked(commissionsApi.list).mockResolvedValue(mockCommissions);
|
||||
|
||||
const { result } = renderHook(() => useMLMCommissions(), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isSuccess).toBe(true);
|
||||
});
|
||||
|
||||
expect(commissionsApi.list).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('useCalculateCommissions', () => {
|
||||
it('should calculate commissions', async () => {
|
||||
const mockResult = {
|
||||
calculated: 15,
|
||||
totalAmount: 7500,
|
||||
byLevel: [],
|
||||
};
|
||||
|
||||
vi.mocked(commissionsApi.calculate).mockResolvedValue(mockResult);
|
||||
|
||||
const { result } = renderHook(() => useCalculateCommissions(), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
result.current.mutate({
|
||||
structure_id: 'struct-1',
|
||||
period_start: '2024-01-01',
|
||||
period_end: '2024-01-31',
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isSuccess).toBe(true);
|
||||
});
|
||||
|
||||
expect(commissionsApi.calculate).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
535
src/__tests__/hooks/usePortfolio.test.tsx
Normal file
535
src/__tests__/hooks/usePortfolio.test.tsx
Normal file
@ -0,0 +1,535 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { renderHook, waitFor } from '@testing-library/react';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import type { ReactNode } from 'react';
|
||||
import {
|
||||
useCategories,
|
||||
useCategory,
|
||||
useCategoryTree,
|
||||
useCreateCategory,
|
||||
useUpdateCategory,
|
||||
useDeleteCategory,
|
||||
useProducts,
|
||||
useProduct,
|
||||
useCreateProduct,
|
||||
useUpdateProduct,
|
||||
useDeleteProduct,
|
||||
useDuplicateProduct,
|
||||
useProductVariants,
|
||||
useCreateVariant,
|
||||
useDeleteVariant,
|
||||
useProductPrices,
|
||||
useCreatePrice,
|
||||
useDeletePrice,
|
||||
} from '@/hooks/usePortfolio';
|
||||
import { categoriesApi, productsApi } from '@/services/portfolio';
|
||||
|
||||
// Mock the API modules
|
||||
vi.mock('@/services/portfolio', () => ({
|
||||
categoriesApi: {
|
||||
list: vi.fn(),
|
||||
get: vi.fn(),
|
||||
getTree: vi.fn(),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
},
|
||||
productsApi: {
|
||||
list: vi.fn(),
|
||||
get: vi.fn(),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
updateStatus: vi.fn(),
|
||||
duplicate: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
getVariants: vi.fn(),
|
||||
createVariant: vi.fn(),
|
||||
updateVariant: vi.fn(),
|
||||
deleteVariant: vi.fn(),
|
||||
getPrices: vi.fn(),
|
||||
createPrice: vi.fn(),
|
||||
updatePrice: vi.fn(),
|
||||
deletePrice: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// Create wrapper for react-query
|
||||
const createWrapper = () => {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
});
|
||||
return ({ children }: { children: ReactNode }) => (
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
);
|
||||
};
|
||||
|
||||
describe('usePortfolio hooks', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
// ==================== Categories ====================
|
||||
|
||||
describe('useCategories', () => {
|
||||
it('should fetch all categories', async () => {
|
||||
const mockCategories = [
|
||||
{ id: 'cat-1', name: 'Electronics', slug: 'electronics', parentId: null },
|
||||
{ id: 'cat-2', name: 'Clothing', slug: 'clothing', parentId: null },
|
||||
];
|
||||
|
||||
vi.mocked(categoriesApi.list).mockResolvedValue(mockCategories);
|
||||
|
||||
const { result } = renderHook(() => useCategories(), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isSuccess).toBe(true);
|
||||
});
|
||||
|
||||
expect(categoriesApi.list).toHaveBeenCalled();
|
||||
expect(result.current.data).toEqual(mockCategories);
|
||||
});
|
||||
|
||||
it('should fetch with filters', async () => {
|
||||
const filters = { parent_id: 'cat-1' };
|
||||
vi.mocked(categoriesApi.list).mockResolvedValue([]);
|
||||
|
||||
const { result } = renderHook(() => useCategories(filters), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isSuccess).toBe(true);
|
||||
});
|
||||
|
||||
expect(categoriesApi.list).toHaveBeenCalledWith(filters);
|
||||
});
|
||||
});
|
||||
|
||||
describe('useCategory', () => {
|
||||
it('should fetch single category', async () => {
|
||||
const mockCategory = {
|
||||
id: 'cat-1',
|
||||
name: 'Electronics',
|
||||
slug: 'electronics',
|
||||
description: 'Electronic devices',
|
||||
};
|
||||
|
||||
vi.mocked(categoriesApi.get).mockResolvedValue(mockCategory);
|
||||
|
||||
const { result } = renderHook(() => useCategory('cat-1'), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isSuccess).toBe(true);
|
||||
});
|
||||
|
||||
expect(categoriesApi.get).toHaveBeenCalledWith('cat-1');
|
||||
expect(result.current.data).toEqual(mockCategory);
|
||||
});
|
||||
|
||||
it('should not fetch when id is empty', () => {
|
||||
const { result } = renderHook(() => useCategory(''), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
expect(result.current.isFetching).toBe(false);
|
||||
expect(categoriesApi.get).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('useCategoryTree', () => {
|
||||
it('should fetch category tree', async () => {
|
||||
const mockTree = [
|
||||
{
|
||||
id: 'cat-1',
|
||||
name: 'Electronics',
|
||||
children: [
|
||||
{ id: 'cat-3', name: 'Phones', children: [] },
|
||||
{ id: 'cat-4', name: 'Laptops', children: [] },
|
||||
],
|
||||
},
|
||||
{ id: 'cat-2', name: 'Clothing', children: [] },
|
||||
];
|
||||
|
||||
vi.mocked(categoriesApi.getTree).mockResolvedValue(mockTree);
|
||||
|
||||
const { result } = renderHook(() => useCategoryTree(), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isSuccess).toBe(true);
|
||||
});
|
||||
|
||||
expect(categoriesApi.getTree).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('useCreateCategory', () => {
|
||||
it('should create new category', async () => {
|
||||
const newCategory = { id: 'cat-5', name: 'Accessories', slug: 'accessories' };
|
||||
vi.mocked(categoriesApi.create).mockResolvedValue(newCategory);
|
||||
|
||||
const { result } = renderHook(() => useCreateCategory(), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
result.current.mutate({
|
||||
name: 'Accessories',
|
||||
slug: 'accessories',
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isSuccess).toBe(true);
|
||||
});
|
||||
|
||||
expect(categoriesApi.create).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('useUpdateCategory', () => {
|
||||
it('should update category', async () => {
|
||||
const updatedCategory = { id: 'cat-1', name: 'Updated Electronics' };
|
||||
vi.mocked(categoriesApi.update).mockResolvedValue(updatedCategory);
|
||||
|
||||
const { result } = renderHook(() => useUpdateCategory(), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
result.current.mutate({
|
||||
id: 'cat-1',
|
||||
data: { name: 'Updated Electronics' },
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isSuccess).toBe(true);
|
||||
});
|
||||
|
||||
expect(categoriesApi.update).toHaveBeenCalledWith('cat-1', { name: 'Updated Electronics' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('useDeleteCategory', () => {
|
||||
it('should delete category', async () => {
|
||||
vi.mocked(categoriesApi.delete).mockResolvedValue(undefined);
|
||||
|
||||
const { result } = renderHook(() => useDeleteCategory(), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
result.current.mutate('cat-1');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isSuccess).toBe(true);
|
||||
});
|
||||
|
||||
expect(categoriesApi.delete).toHaveBeenCalledWith('cat-1');
|
||||
});
|
||||
});
|
||||
|
||||
// ==================== Products ====================
|
||||
|
||||
describe('useProducts', () => {
|
||||
it('should fetch all products', async () => {
|
||||
const mockProducts = [
|
||||
{ id: 'prod-1', name: 'iPhone 15', sku: 'IP15', price: 999, status: 'active' },
|
||||
{ id: 'prod-2', name: 'MacBook Pro', sku: 'MBP', price: 1999, status: 'active' },
|
||||
];
|
||||
|
||||
vi.mocked(productsApi.list).mockResolvedValue(mockProducts);
|
||||
|
||||
const { result } = renderHook(() => useProducts(), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isSuccess).toBe(true);
|
||||
});
|
||||
|
||||
expect(productsApi.list).toHaveBeenCalled();
|
||||
expect(result.current.data).toEqual(mockProducts);
|
||||
});
|
||||
|
||||
it('should fetch with filters', async () => {
|
||||
const filters = { category_id: 'cat-1', status: 'active' as const };
|
||||
vi.mocked(productsApi.list).mockResolvedValue([]);
|
||||
|
||||
const { result } = renderHook(() => useProducts(filters), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isSuccess).toBe(true);
|
||||
});
|
||||
|
||||
expect(productsApi.list).toHaveBeenCalledWith(filters);
|
||||
});
|
||||
});
|
||||
|
||||
describe('useProduct', () => {
|
||||
it('should fetch single product', async () => {
|
||||
const mockProduct = {
|
||||
id: 'prod-1',
|
||||
name: 'iPhone 15',
|
||||
sku: 'IP15',
|
||||
price: 999,
|
||||
description: 'Latest iPhone',
|
||||
};
|
||||
|
||||
vi.mocked(productsApi.get).mockResolvedValue(mockProduct);
|
||||
|
||||
const { result } = renderHook(() => useProduct('prod-1'), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isSuccess).toBe(true);
|
||||
});
|
||||
|
||||
expect(productsApi.get).toHaveBeenCalledWith('prod-1');
|
||||
});
|
||||
|
||||
it('should not fetch when id is empty', () => {
|
||||
const { result } = renderHook(() => useProduct(''), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
expect(result.current.isFetching).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('useCreateProduct', () => {
|
||||
it('should create new product', async () => {
|
||||
const newProduct = { id: 'prod-3', name: 'iPad', sku: 'IPAD' };
|
||||
vi.mocked(productsApi.create).mockResolvedValue(newProduct);
|
||||
|
||||
const { result } = renderHook(() => useCreateProduct(), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
result.current.mutate({
|
||||
name: 'iPad',
|
||||
sku: 'IPAD',
|
||||
category_id: 'cat-1',
|
||||
base_price: 799,
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isSuccess).toBe(true);
|
||||
});
|
||||
|
||||
expect(productsApi.create).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('useUpdateProduct', () => {
|
||||
it('should update product', async () => {
|
||||
const updatedProduct = { id: 'prod-1', name: 'iPhone 15 Pro' };
|
||||
vi.mocked(productsApi.update).mockResolvedValue(updatedProduct);
|
||||
|
||||
const { result } = renderHook(() => useUpdateProduct(), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
result.current.mutate({
|
||||
id: 'prod-1',
|
||||
data: { name: 'iPhone 15 Pro' },
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isSuccess).toBe(true);
|
||||
});
|
||||
|
||||
expect(productsApi.update).toHaveBeenCalledWith('prod-1', { name: 'iPhone 15 Pro' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('useDeleteProduct', () => {
|
||||
it('should delete product', async () => {
|
||||
vi.mocked(productsApi.delete).mockResolvedValue(undefined);
|
||||
|
||||
const { result } = renderHook(() => useDeleteProduct(), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
result.current.mutate('prod-1');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isSuccess).toBe(true);
|
||||
});
|
||||
|
||||
expect(productsApi.delete).toHaveBeenCalledWith('prod-1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('useDuplicateProduct', () => {
|
||||
it('should duplicate product', async () => {
|
||||
const duplicatedProduct = { id: 'prod-4', name: 'iPhone 15 (Copy)', sku: 'IP15-COPY' };
|
||||
vi.mocked(productsApi.duplicate).mockResolvedValue(duplicatedProduct);
|
||||
|
||||
const { result } = renderHook(() => useDuplicateProduct(), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
result.current.mutate('prod-1');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isSuccess).toBe(true);
|
||||
});
|
||||
|
||||
expect(productsApi.duplicate).toHaveBeenCalledWith('prod-1');
|
||||
});
|
||||
});
|
||||
|
||||
// ==================== Variants ====================
|
||||
|
||||
describe('useProductVariants', () => {
|
||||
it('should fetch product variants', async () => {
|
||||
const mockVariants = [
|
||||
{ id: 'var-1', productId: 'prod-1', name: '128GB', sku: 'IP15-128' },
|
||||
{ id: 'var-2', productId: 'prod-1', name: '256GB', sku: 'IP15-256' },
|
||||
];
|
||||
|
||||
vi.mocked(productsApi.getVariants).mockResolvedValue(mockVariants);
|
||||
|
||||
const { result } = renderHook(() => useProductVariants('prod-1'), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isSuccess).toBe(true);
|
||||
});
|
||||
|
||||
expect(productsApi.getVariants).toHaveBeenCalledWith('prod-1');
|
||||
});
|
||||
|
||||
it('should not fetch when productId is empty', () => {
|
||||
const { result } = renderHook(() => useProductVariants(''), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
expect(result.current.isFetching).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('useCreateVariant', () => {
|
||||
it('should create variant', async () => {
|
||||
const newVariant = { id: 'var-3', name: '512GB', sku: 'IP15-512' };
|
||||
vi.mocked(productsApi.createVariant).mockResolvedValue(newVariant);
|
||||
|
||||
const { result } = renderHook(() => useCreateVariant(), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
result.current.mutate({
|
||||
productId: 'prod-1',
|
||||
data: { name: '512GB', sku: 'IP15-512', price_adjustment: 200 },
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isSuccess).toBe(true);
|
||||
});
|
||||
|
||||
expect(productsApi.createVariant).toHaveBeenCalledWith('prod-1', {
|
||||
name: '512GB',
|
||||
sku: 'IP15-512',
|
||||
price_adjustment: 200,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('useDeleteVariant', () => {
|
||||
it('should delete variant', async () => {
|
||||
vi.mocked(productsApi.deleteVariant).mockResolvedValue(undefined);
|
||||
|
||||
const { result } = renderHook(() => useDeleteVariant(), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
result.current.mutate({ productId: 'prod-1', variantId: 'var-1' });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isSuccess).toBe(true);
|
||||
});
|
||||
|
||||
expect(productsApi.deleteVariant).toHaveBeenCalledWith('prod-1', 'var-1');
|
||||
});
|
||||
});
|
||||
|
||||
// ==================== Prices ====================
|
||||
|
||||
describe('useProductPrices', () => {
|
||||
it('should fetch product prices', async () => {
|
||||
const mockPrices = [
|
||||
{ id: 'price-1', productId: 'prod-1', currency: 'USD', amount: 999 },
|
||||
{ id: 'price-2', productId: 'prod-1', currency: 'EUR', amount: 899 },
|
||||
];
|
||||
|
||||
vi.mocked(productsApi.getPrices).mockResolvedValue(mockPrices);
|
||||
|
||||
const { result } = renderHook(() => useProductPrices('prod-1'), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isSuccess).toBe(true);
|
||||
});
|
||||
|
||||
expect(productsApi.getPrices).toHaveBeenCalledWith('prod-1');
|
||||
});
|
||||
|
||||
it('should not fetch when productId is empty', () => {
|
||||
const { result } = renderHook(() => useProductPrices(''), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
expect(result.current.isFetching).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('useCreatePrice', () => {
|
||||
it('should create price', async () => {
|
||||
const newPrice = { id: 'price-3', currency: 'GBP', amount: 799 };
|
||||
vi.mocked(productsApi.createPrice).mockResolvedValue(newPrice);
|
||||
|
||||
const { result } = renderHook(() => useCreatePrice(), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
result.current.mutate({
|
||||
productId: 'prod-1',
|
||||
data: { currency: 'GBP', amount: 799 },
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isSuccess).toBe(true);
|
||||
});
|
||||
|
||||
expect(productsApi.createPrice).toHaveBeenCalledWith('prod-1', { currency: 'GBP', amount: 799 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('useDeletePrice', () => {
|
||||
it('should delete price', async () => {
|
||||
vi.mocked(productsApi.deletePrice).mockResolvedValue(undefined);
|
||||
|
||||
const { result } = renderHook(() => useDeletePrice(), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
result.current.mutate({ productId: 'prod-1', priceId: 'price-1' });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isSuccess).toBe(true);
|
||||
});
|
||||
|
||||
expect(productsApi.deletePrice).toHaveBeenCalledWith('prod-1', 'price-1');
|
||||
});
|
||||
});
|
||||
});
|
||||
347
src/__tests__/hooks/useRbac.test.tsx
Normal file
347
src/__tests__/hooks/useRbac.test.tsx
Normal file
@ -0,0 +1,347 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { renderHook, waitFor } from '@testing-library/react';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import type { ReactNode } from 'react';
|
||||
import {
|
||||
useRoles,
|
||||
useRole,
|
||||
useCreateRole,
|
||||
useUpdateRole,
|
||||
useDeleteRole,
|
||||
usePermissions,
|
||||
useMyRoles,
|
||||
useMyPermissions,
|
||||
useCheckPermission,
|
||||
useUserRoles,
|
||||
getPermissionCategoryLabel,
|
||||
getPermissionActionLabel,
|
||||
parsePermissionSlug,
|
||||
} from '@/hooks/useRbac';
|
||||
import { rbacApi } from '@/services/api';
|
||||
|
||||
// Mock the API module
|
||||
vi.mock('@/services/api', () => ({
|
||||
rbacApi: {
|
||||
listRoles: vi.fn(),
|
||||
getRole: vi.fn(),
|
||||
createRole: vi.fn(),
|
||||
updateRole: vi.fn(),
|
||||
deleteRole: vi.fn(),
|
||||
listPermissions: vi.fn(),
|
||||
getPermissionsByCategory: vi.fn(),
|
||||
getUserRoles: vi.fn(),
|
||||
getUserPermissions: vi.fn(),
|
||||
getMyRoles: vi.fn(),
|
||||
getMyPermissions: vi.fn(),
|
||||
checkPermission: vi.fn(),
|
||||
assignRoleToUser: vi.fn(),
|
||||
removeRoleFromUser: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// Create wrapper for react-query
|
||||
const createWrapper = () => {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
});
|
||||
return ({ children }: { children: ReactNode }) => (
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
);
|
||||
};
|
||||
|
||||
describe('useRbac hooks', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('useRoles', () => {
|
||||
it('should fetch all roles', async () => {
|
||||
const mockRoles = [
|
||||
{ id: 'role-1', name: 'Admin', slug: 'admin', is_system: true },
|
||||
{ id: 'role-2', name: 'User', slug: 'user', is_system: true },
|
||||
];
|
||||
|
||||
vi.mocked(rbacApi.listRoles).mockResolvedValue(mockRoles);
|
||||
|
||||
const { result } = renderHook(() => useRoles(), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isSuccess).toBe(true);
|
||||
});
|
||||
|
||||
expect(rbacApi.listRoles).toHaveBeenCalled();
|
||||
expect(result.current.data).toEqual(mockRoles);
|
||||
});
|
||||
});
|
||||
|
||||
describe('useRole', () => {
|
||||
it('should fetch single role by id', async () => {
|
||||
const mockRole = {
|
||||
id: 'role-1',
|
||||
name: 'Admin',
|
||||
slug: 'admin',
|
||||
is_system: true,
|
||||
permissions: [{ id: 'perm-1', slug: 'users:read' }],
|
||||
};
|
||||
|
||||
vi.mocked(rbacApi.getRole).mockResolvedValue(mockRole);
|
||||
|
||||
const { result } = renderHook(() => useRole('role-1'), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isSuccess).toBe(true);
|
||||
});
|
||||
|
||||
expect(rbacApi.getRole).toHaveBeenCalledWith('role-1');
|
||||
expect(result.current.data).toEqual(mockRole);
|
||||
});
|
||||
|
||||
it('should not fetch when id is empty', () => {
|
||||
const { result } = renderHook(() => useRole(''), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
expect(result.current.isFetching).toBe(false);
|
||||
expect(rbacApi.getRole).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('useCreateRole', () => {
|
||||
it('should create a new role', async () => {
|
||||
const newRole = {
|
||||
id: 'role-3',
|
||||
name: 'Manager',
|
||||
slug: 'manager',
|
||||
is_system: false,
|
||||
};
|
||||
|
||||
vi.mocked(rbacApi.createRole).mockResolvedValue(newRole);
|
||||
|
||||
const { result } = renderHook(() => useCreateRole(), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
result.current.mutate({
|
||||
name: 'Manager',
|
||||
slug: 'manager',
|
||||
permissions: ['users:read'],
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isSuccess).toBe(true);
|
||||
});
|
||||
|
||||
expect(rbacApi.createRole).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('useUpdateRole', () => {
|
||||
it('should update an existing role', async () => {
|
||||
const updatedRole = {
|
||||
id: 'role-1',
|
||||
name: 'Super Admin',
|
||||
slug: 'admin',
|
||||
is_system: true,
|
||||
};
|
||||
|
||||
vi.mocked(rbacApi.updateRole).mockResolvedValue(updatedRole);
|
||||
|
||||
const { result } = renderHook(() => useUpdateRole(), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
result.current.mutate({
|
||||
id: 'role-1',
|
||||
data: { name: 'Super Admin' },
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isSuccess).toBe(true);
|
||||
});
|
||||
|
||||
expect(rbacApi.updateRole).toHaveBeenCalledWith('role-1', { name: 'Super Admin' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('useDeleteRole', () => {
|
||||
it('should delete a role', async () => {
|
||||
vi.mocked(rbacApi.deleteRole).mockResolvedValue(undefined);
|
||||
|
||||
const { result } = renderHook(() => useDeleteRole(), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
result.current.mutate('role-1');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isSuccess).toBe(true);
|
||||
});
|
||||
|
||||
expect(rbacApi.deleteRole).toHaveBeenCalledWith('role-1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('usePermissions', () => {
|
||||
it('should fetch all permissions', async () => {
|
||||
const mockPermissions = [
|
||||
{ id: 'perm-1', slug: 'users:read', name: 'Read Users', category: 'users' },
|
||||
{ id: 'perm-2', slug: 'users:write', name: 'Write Users', category: 'users' },
|
||||
];
|
||||
|
||||
vi.mocked(rbacApi.listPermissions).mockResolvedValue(mockPermissions);
|
||||
|
||||
const { result } = renderHook(() => usePermissions(), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isSuccess).toBe(true);
|
||||
});
|
||||
|
||||
expect(rbacApi.listPermissions).toHaveBeenCalled();
|
||||
expect(result.current.data).toEqual(mockPermissions);
|
||||
});
|
||||
});
|
||||
|
||||
describe('useUserRoles', () => {
|
||||
it('should fetch user roles', async () => {
|
||||
const mockUserRoles = [
|
||||
{ id: 'ur-1', user_id: 'user-1', role_id: 'role-1' },
|
||||
];
|
||||
|
||||
vi.mocked(rbacApi.getUserRoles).mockResolvedValue(mockUserRoles);
|
||||
|
||||
const { result } = renderHook(() => useUserRoles('user-1'), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isSuccess).toBe(true);
|
||||
});
|
||||
|
||||
expect(rbacApi.getUserRoles).toHaveBeenCalledWith('user-1');
|
||||
});
|
||||
|
||||
it('should not fetch when userId is empty', () => {
|
||||
const { result } = renderHook(() => useUserRoles(''), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
expect(result.current.isFetching).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('useMyRoles', () => {
|
||||
it('should fetch current user roles', async () => {
|
||||
const mockRoles = [
|
||||
{ id: 'role-1', name: 'Admin', slug: 'admin' },
|
||||
];
|
||||
|
||||
vi.mocked(rbacApi.getMyRoles).mockResolvedValue(mockRoles);
|
||||
|
||||
const { result } = renderHook(() => useMyRoles(), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isSuccess).toBe(true);
|
||||
});
|
||||
|
||||
expect(rbacApi.getMyRoles).toHaveBeenCalled();
|
||||
expect(result.current.data).toEqual(mockRoles);
|
||||
});
|
||||
});
|
||||
|
||||
describe('useMyPermissions', () => {
|
||||
it('should fetch current user permissions', async () => {
|
||||
const mockPermissions = ['users:read', 'users:write', 'billing:read'];
|
||||
|
||||
vi.mocked(rbacApi.getMyPermissions).mockResolvedValue(mockPermissions);
|
||||
|
||||
const { result } = renderHook(() => useMyPermissions(), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isSuccess).toBe(true);
|
||||
});
|
||||
|
||||
expect(rbacApi.getMyPermissions).toHaveBeenCalled();
|
||||
expect(result.current.data).toEqual(mockPermissions);
|
||||
});
|
||||
});
|
||||
|
||||
describe('useCheckPermission', () => {
|
||||
it('should check if user has permission', async () => {
|
||||
vi.mocked(rbacApi.checkPermission).mockResolvedValue({ hasPermission: true });
|
||||
|
||||
const { result } = renderHook(() => useCheckPermission('users:read'), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isSuccess).toBe(true);
|
||||
});
|
||||
|
||||
expect(rbacApi.checkPermission).toHaveBeenCalledWith('users:read');
|
||||
expect(result.current.data).toEqual({ hasPermission: true });
|
||||
});
|
||||
|
||||
it('should not check when permission is empty', () => {
|
||||
const { result } = renderHook(() => useCheckPermission(''), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
expect(result.current.isFetching).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('helper functions', () => {
|
||||
describe('getPermissionCategoryLabel', () => {
|
||||
it('should return correct label for known categories', () => {
|
||||
expect(getPermissionCategoryLabel('users')).toBe('Users');
|
||||
expect(getPermissionCategoryLabel('roles')).toBe('Roles & Permissions');
|
||||
expect(getPermissionCategoryLabel('billing')).toBe('Billing');
|
||||
expect(getPermissionCategoryLabel('audit')).toBe('Audit Logs');
|
||||
expect(getPermissionCategoryLabel('ai')).toBe('AI Integration');
|
||||
});
|
||||
|
||||
it('should return original value for unknown categories', () => {
|
||||
expect(getPermissionCategoryLabel('custom')).toBe('custom');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getPermissionActionLabel', () => {
|
||||
it('should return correct label for known actions', () => {
|
||||
expect(getPermissionActionLabel('read')).toBe('View');
|
||||
expect(getPermissionActionLabel('write')).toBe('Create/Edit');
|
||||
expect(getPermissionActionLabel('delete')).toBe('Delete');
|
||||
expect(getPermissionActionLabel('manage')).toBe('Manage');
|
||||
});
|
||||
|
||||
it('should return original value for unknown actions', () => {
|
||||
expect(getPermissionActionLabel('custom')).toBe('custom');
|
||||
});
|
||||
});
|
||||
|
||||
describe('parsePermissionSlug', () => {
|
||||
it('should parse permission slug correctly', () => {
|
||||
expect(parsePermissionSlug('users:read')).toEqual({
|
||||
category: 'users',
|
||||
action: 'read',
|
||||
});
|
||||
expect(parsePermissionSlug('billing:write')).toEqual({
|
||||
category: 'billing',
|
||||
action: 'write',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -76,9 +76,11 @@ export function AuditFilters({
|
||||
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3">
|
||||
{/* Search input */}
|
||||
{onSearchChange && (
|
||||
<form onSubmit={handleSearchSubmit} className="relative flex-1 max-w-md">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-secondary-400" />
|
||||
<form onSubmit={handleSearchSubmit} className="relative flex-1 max-w-md" role="search">
|
||||
<label htmlFor="audit-search" className="sr-only">Search audit logs</label>
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-secondary-400" aria-hidden="true" />
|
||||
<input
|
||||
id="audit-search"
|
||||
type="text"
|
||||
value={localSearch}
|
||||
onChange={(e) => setLocalSearch(e.target.value)}
|
||||
@ -89,9 +91,10 @@ export function AuditFilters({
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSearchClear}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-secondary-400 hover:text-secondary-600"
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-secondary-400 hover:text-secondary-600 focus:outline-none focus:ring-2 focus:ring-primary-500 rounded"
|
||||
aria-label="Clear search"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
<X className="w-4 h-4" aria-hidden="true" />
|
||||
</button>
|
||||
)}
|
||||
</form>
|
||||
@ -101,16 +104,18 @@ export function AuditFilters({
|
||||
<button
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className={clsx(
|
||||
'flex items-center gap-2 px-3 py-2 rounded-lg text-sm font-medium transition-colors',
|
||||
'flex items-center gap-2 px-3 py-2 rounded-lg text-sm font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-primary-500',
|
||||
isOpen || activeFiltersCount > 0
|
||||
? 'bg-primary-100 text-primary-700 dark:bg-primary-900/30 dark:text-primary-400'
|
||||
: 'bg-secondary-100 text-secondary-700 dark:bg-secondary-700 dark:text-secondary-300 hover:bg-secondary-200 dark:hover:bg-secondary-600'
|
||||
)}
|
||||
aria-expanded={isOpen}
|
||||
aria-controls="audit-filters-panel"
|
||||
>
|
||||
<Filter className="w-4 h-4" />
|
||||
<Filter className="w-4 h-4" aria-hidden="true" />
|
||||
Filters
|
||||
{activeFiltersCount > 0 && (
|
||||
<span className="px-1.5 py-0.5 bg-primary-600 text-white text-xs rounded-full">
|
||||
<span className="px-1.5 py-0.5 bg-primary-600 text-white text-xs rounded-full" aria-label={`${activeFiltersCount} active filters`}>
|
||||
{activeFiltersCount}
|
||||
</span>
|
||||
)}
|
||||
@ -119,9 +124,10 @@ export function AuditFilters({
|
||||
{activeFiltersCount > 0 && (
|
||||
<button
|
||||
onClick={clearFilters}
|
||||
className="flex items-center gap-1 text-sm text-secondary-500 hover:text-secondary-700 dark:hover:text-secondary-300"
|
||||
className="flex items-center gap-1 text-sm text-secondary-500 hover:text-secondary-700 dark:hover:text-secondary-300 focus:outline-none focus:ring-2 focus:ring-primary-500 rounded"
|
||||
aria-label="Clear all filters"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
<X className="w-4 h-4" aria-hidden="true" />
|
||||
Clear all
|
||||
</button>
|
||||
)}
|
||||
@ -130,14 +136,15 @@ export function AuditFilters({
|
||||
|
||||
{/* Filter panel */}
|
||||
{isOpen && (
|
||||
<div className="p-4 bg-secondary-50 dark:bg-secondary-800/50 rounded-lg border border-secondary-200 dark:border-secondary-700">
|
||||
<div id="audit-filters-panel" className="p-4 bg-secondary-50 dark:bg-secondary-800/50 rounded-lg border border-secondary-200 dark:border-secondary-700" role="region" aria-label="Audit log filters">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-4">
|
||||
{/* Action filter */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">
|
||||
<label htmlFor="audit-filter-action" className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">
|
||||
Action
|
||||
</label>
|
||||
<select
|
||||
id="audit-filter-action"
|
||||
value={filters.action || ''}
|
||||
onChange={(e) => handleChange('action', e.target.value)}
|
||||
className="w-full px-3 py-2 rounded-lg border border-secondary-300 dark:border-secondary-600 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-secondary-100 focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
||||
@ -153,10 +160,11 @@ export function AuditFilters({
|
||||
|
||||
{/* Entity type filter */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">
|
||||
<label htmlFor="audit-filter-entity" className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">
|
||||
Entity Type
|
||||
</label>
|
||||
<select
|
||||
id="audit-filter-entity"
|
||||
value={filters.entity_type || ''}
|
||||
onChange={(e) => handleChange('entity_type', e.target.value)}
|
||||
className="w-full px-3 py-2 rounded-lg border border-secondary-300 dark:border-secondary-600 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-secondary-100 focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
||||
@ -173,10 +181,11 @@ export function AuditFilters({
|
||||
{/* User filter */}
|
||||
{users.length > 0 && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">
|
||||
<label htmlFor="audit-filter-user" className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">
|
||||
User
|
||||
</label>
|
||||
<select
|
||||
id="audit-filter-user"
|
||||
value={filters.user_id || ''}
|
||||
onChange={(e) => handleChange('user_id', e.target.value)}
|
||||
className="w-full px-3 py-2 rounded-lg border border-secondary-300 dark:border-secondary-600 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-secondary-100 focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
||||
@ -193,10 +202,11 @@ export function AuditFilters({
|
||||
|
||||
{/* From date filter */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">
|
||||
<label htmlFor="audit-filter-from-date" className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">
|
||||
From Date
|
||||
</label>
|
||||
<input
|
||||
id="audit-filter-from-date"
|
||||
type="date"
|
||||
value={filters.from_date || ''}
|
||||
onChange={(e) => handleChange('from_date', e.target.value)}
|
||||
@ -206,10 +216,11 @@ export function AuditFilters({
|
||||
|
||||
{/* To date filter */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">
|
||||
<label htmlFor="audit-filter-to-date" className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">
|
||||
To Date
|
||||
</label>
|
||||
<input
|
||||
id="audit-filter-to-date"
|
||||
type="date"
|
||||
value={filters.to_date || ''}
|
||||
onChange={(e) => handleChange('to_date', e.target.value)}
|
||||
@ -263,8 +274,12 @@ function FilterBadge({ label, onRemove }: { label: string; onRemove: () => void
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-1 bg-primary-100 dark:bg-primary-900/30 text-primary-700 dark:text-primary-400 rounded-full text-sm">
|
||||
{label}
|
||||
<button onClick={onRemove} className="hover:text-primary-900 dark:hover:text-primary-200">
|
||||
<X className="w-3 h-3" />
|
||||
<button
|
||||
onClick={onRemove}
|
||||
className="hover:text-primary-900 dark:hover:text-primary-200 focus:outline-none focus:ring-2 focus:ring-primary-500 rounded-full"
|
||||
aria-label={`Remove filter: ${label}`}
|
||||
>
|
||||
<X className="w-3 h-3" aria-hidden="true" />
|
||||
</button>
|
||||
</span>
|
||||
);
|
||||
|
||||
@ -72,17 +72,25 @@ export function GoalProgressBar({
|
||||
{/* Progress Bar Container */}
|
||||
<div className="relative">
|
||||
{/* Background Bar */}
|
||||
<div className={`w-full bg-gray-200 rounded-full ${sizeClasses[size]}`}>
|
||||
<div
|
||||
className={`w-full bg-gray-200 rounded-full ${sizeClasses[size]}`}
|
||||
role="progressbar"
|
||||
aria-valuenow={Math.round(percentage)}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
aria-label={`Progress: ${currentValue.toLocaleString()} of ${targetValue.toLocaleString()} ${unit} (${percentage.toFixed(1)}%)`}
|
||||
>
|
||||
{/* Progress Fill */}
|
||||
<div
|
||||
className={`${sizeClasses[size]} rounded-full transition-all duration-500 ${getProgressColor(percentage)}`}
|
||||
style={{ width: `${percentage}%` }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Milestones */}
|
||||
{milestones && milestones.length > 0 && (
|
||||
<div className="absolute inset-0 flex items-center pointer-events-none">
|
||||
<div className="absolute inset-0 flex items-center pointer-events-none" aria-hidden="true">
|
||||
{milestones.map((milestone, index) => (
|
||||
<div
|
||||
key={index}
|
||||
@ -104,7 +112,7 @@ export function GoalProgressBar({
|
||||
|
||||
{/* Milestone Labels */}
|
||||
{milestones && milestones.length > 0 && size === 'lg' && (
|
||||
<div className="relative mt-1 text-xs text-gray-500">
|
||||
<div className="relative mt-1 text-xs text-gray-500" aria-hidden="true">
|
||||
{milestones.map((milestone, index) => (
|
||||
<span
|
||||
key={index}
|
||||
|
||||
@ -30,29 +30,42 @@ function TreeNodeItem({ node, depth, onNodeClick }: TreeNodeItemProps) {
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
handleClick();
|
||||
}
|
||||
};
|
||||
|
||||
const userName = node.user?.firstName && node.user?.lastName
|
||||
? `${node.user.firstName} ${node.user.lastName}`
|
||||
: node.user?.email || 'Unknown';
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
className="flex items-center py-2 px-4 hover:bg-gray-50 border-b border-gray-100 cursor-pointer"
|
||||
<div role="treeitem" aria-expanded={node.children && node.children.length > 0 ? true : undefined}>
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center py-2 px-4 hover:bg-gray-50 border-b border-gray-100 cursor-pointer text-left focus:outline-none focus:ring-2 focus:ring-inset focus:ring-blue-500"
|
||||
style={{ paddingLeft: `${indent + 16}px` }}
|
||||
onClick={handleClick}
|
||||
onKeyDown={handleKeyDown}
|
||||
aria-label={`View details for ${userName}, ${node.rank?.name || 'No Rank'}, Level ${node.depth}`}
|
||||
>
|
||||
{depth > 0 && (
|
||||
<span className="mr-2 text-gray-300">|--</span>
|
||||
<span className="mr-2 text-gray-300" aria-hidden="true">|--</span>
|
||||
)}
|
||||
<div className="flex-1 flex items-center justify-between">
|
||||
<div className="flex items-center">
|
||||
<div
|
||||
className="w-8 h-8 rounded-full flex items-center justify-center text-white text-sm font-medium mr-3"
|
||||
style={{ backgroundColor: node.rank?.color || '#6B7280' }}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{node.user?.firstName?.charAt(0) || node.user?.email?.charAt(0) || '?'}
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-900">
|
||||
{node.user?.firstName && node.user?.lastName
|
||||
? `${node.user.firstName} ${node.user.lastName}`
|
||||
: node.user?.email || 'Unknown'}
|
||||
{userName}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
{node.rank?.name || 'No Rank'} | Level {node.depth}
|
||||
@ -69,10 +82,14 @@ function TreeNodeItem({ node, depth, onNodeClick }: TreeNodeItemProps) {
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{node.children?.map((child) => (
|
||||
<TreeNodeItem key={child.id} node={child} depth={depth + 1} onNodeClick={onNodeClick} />
|
||||
))}
|
||||
</button>
|
||||
{node.children && node.children.length > 0 && (
|
||||
<div role="group" aria-label={`${userName}'s downline`}>
|
||||
{node.children.map((child) => (
|
||||
<TreeNodeItem key={child.id} node={child} depth={depth + 1} onNodeClick={onNodeClick} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -80,12 +97,13 @@ function TreeNodeItem({ node, depth, onNodeClick }: TreeNodeItemProps) {
|
||||
export function NetworkTree({ tree, maxHeight = '600px', onNodeClick }: NetworkTreeProps) {
|
||||
if (!tree) {
|
||||
return (
|
||||
<div className="text-center py-10">
|
||||
<div className="text-center py-10" role="status">
|
||||
<svg
|
||||
className="mx-auto h-12 w-12 text-gray-400"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
@ -100,7 +118,7 @@ export function NetworkTree({ tree, maxHeight = '600px', onNodeClick }: NetworkT
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="overflow-y-auto" style={{ maxHeight }}>
|
||||
<div className="overflow-y-auto" style={{ maxHeight }} role="tree" aria-label="Network hierarchy">
|
||||
<TreeNodeItem node={tree} depth={0} onNodeClick={onNodeClick} />
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -15,8 +15,9 @@ export function NotificationBell() {
|
||||
<>
|
||||
<button
|
||||
onClick={() => setIsDrawerOpen(true)}
|
||||
className="relative p-2 rounded-lg hover:bg-secondary-100 dark:hover:bg-secondary-700 transition-colors"
|
||||
className="relative p-2 rounded-lg hover:bg-secondary-100 dark:hover:bg-secondary-700 transition-colors focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||
aria-label={`Notifications${hasUnread ? ` (${unreadCount} unread)` : ''}`}
|
||||
aria-haspopup="dialog"
|
||||
>
|
||||
<Bell
|
||||
className={clsx(
|
||||
@ -25,9 +26,10 @@ export function NotificationBell() {
|
||||
? 'text-primary-600 dark:text-primary-400'
|
||||
: 'text-secondary-600 dark:text-secondary-400'
|
||||
)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{hasUnread && (
|
||||
<span className="absolute top-1 right-1 flex items-center justify-center min-w-[18px] h-[18px] px-1 text-[10px] font-bold text-white bg-red-500 rounded-full">
|
||||
<span className="absolute top-1 right-1 flex items-center justify-center min-w-[18px] h-[18px] px-1 text-[10px] font-bold text-white bg-red-500 rounded-full" aria-hidden="true">
|
||||
{unreadCount > 99 ? '99+' : unreadCount}
|
||||
</span>
|
||||
)}
|
||||
|
||||
@ -38,11 +38,15 @@ export function NotificationDrawer({ isOpen, onClose }: NotificationDrawerProps)
|
||||
<div
|
||||
className="fixed inset-0 z-40 bg-black/20 dark:bg-black/40"
|
||||
onClick={onClose}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Drawer */}
|
||||
<div
|
||||
<aside
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Notifications panel"
|
||||
className={clsx(
|
||||
'fixed top-0 right-0 z-50 h-full w-full sm:w-96 bg-white dark:bg-secondary-800 shadow-xl transform transition-transform duration-300 ease-in-out',
|
||||
isOpen ? 'translate-x-0' : 'translate-x-full'
|
||||
@ -51,8 +55,8 @@ export function NotificationDrawer({ isOpen, onClose }: NotificationDrawerProps)
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between h-16 px-4 border-b border-secondary-200 dark:border-secondary-700">
|
||||
<div className="flex items-center gap-2">
|
||||
<Bell className="w-5 h-5 text-secondary-600 dark:text-secondary-400" />
|
||||
<h2 className="text-lg font-semibold text-secondary-900 dark:text-secondary-100">
|
||||
<Bell className="w-5 h-5 text-secondary-600 dark:text-secondary-400" aria-hidden="true" />
|
||||
<h2 id="notifications-drawer-title" className="text-lg font-semibold text-secondary-900 dark:text-secondary-100">
|
||||
Notifications
|
||||
</h2>
|
||||
</div>
|
||||
@ -61,21 +65,23 @@ export function NotificationDrawer({ isOpen, onClose }: NotificationDrawerProps)
|
||||
<button
|
||||
onClick={handleMarkAllAsRead}
|
||||
disabled={markAllAsRead.isPending}
|
||||
className="flex items-center gap-1 px-2 py-1 text-xs font-medium text-primary-600 hover:bg-primary-50 dark:hover:bg-primary-900/20 rounded-md transition-colors disabled:opacity-50"
|
||||
className="flex items-center gap-1 px-2 py-1 text-xs font-medium text-primary-600 hover:bg-primary-50 dark:hover:bg-primary-900/20 rounded-md transition-colors disabled:opacity-50 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-1"
|
||||
aria-label="Mark all notifications as read"
|
||||
>
|
||||
{markAllAsRead.isPending ? (
|
||||
<Loader2 className="w-3 h-3 animate-spin" />
|
||||
<Loader2 className="w-3 h-3 animate-spin" aria-hidden="true" />
|
||||
) : (
|
||||
<CheckCheck className="w-3 h-3" />
|
||||
<CheckCheck className="w-3 h-3" aria-hidden="true" />
|
||||
)}
|
||||
Mark all read
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 rounded-lg hover:bg-secondary-100 dark:hover:bg-secondary-700 transition-colors"
|
||||
className="p-2 rounded-lg hover:bg-secondary-100 dark:hover:bg-secondary-700 transition-colors focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-1"
|
||||
aria-label="Close notifications panel"
|
||||
>
|
||||
<X className="w-5 h-5 text-secondary-600 dark:text-secondary-400" />
|
||||
<X className="w-5 h-5 text-secondary-600 dark:text-secondary-400" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@ -83,12 +89,12 @@ export function NotificationDrawer({ isOpen, onClose }: NotificationDrawerProps)
|
||||
{/* Content */}
|
||||
<div className="h-[calc(100%-4rem)] overflow-y-auto">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center h-32">
|
||||
<Loader2 className="w-6 h-6 animate-spin text-primary-500" />
|
||||
<div className="flex items-center justify-center h-32" role="status" aria-label="Loading notifications">
|
||||
<Loader2 className="w-6 h-6 animate-spin text-primary-500" aria-hidden="true" />
|
||||
</div>
|
||||
) : notifications.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-64 text-center px-4">
|
||||
<div className="w-16 h-16 bg-secondary-100 dark:bg-secondary-700 rounded-full flex items-center justify-center mb-4">
|
||||
<div className="w-16 h-16 bg-secondary-100 dark:bg-secondary-700 rounded-full flex items-center justify-center mb-4" aria-hidden="true">
|
||||
<Bell className="w-8 h-8 text-secondary-400" />
|
||||
</div>
|
||||
<p className="text-secondary-600 dark:text-secondary-400 font-medium">
|
||||
@ -99,19 +105,20 @@ export function NotificationDrawer({ isOpen, onClose }: NotificationDrawerProps)
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-secondary-100 dark:divide-secondary-700">
|
||||
<ul className="divide-y divide-secondary-100 dark:divide-secondary-700" role="list" aria-label="Notifications list">
|
||||
{notifications.map((notification) => (
|
||||
<NotificationItem
|
||||
key={notification.id}
|
||||
notification={notification}
|
||||
onRead={handleMarkAsRead}
|
||||
onNavigate={handleNavigate}
|
||||
/>
|
||||
<li key={notification.id}>
|
||||
<NotificationItem
|
||||
notification={notification}
|
||||
onRead={handleMarkAsRead}
|
||||
onNavigate={handleNavigate}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
</div>
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@ -45,14 +45,15 @@ export function NotificationItem({ notification, onRead, onNavigate }: Notificat
|
||||
<button
|
||||
onClick={handleClick}
|
||||
className={clsx(
|
||||
'w-full flex items-start gap-3 p-3 text-left transition-colors rounded-lg',
|
||||
'w-full flex items-start gap-3 p-3 text-left transition-colors rounded-lg focus:outline-none focus:ring-2 focus:ring-inset focus:ring-primary-500',
|
||||
isRead
|
||||
? 'bg-transparent hover:bg-secondary-50 dark:hover:bg-secondary-800'
|
||||
: 'bg-primary-50/50 dark:bg-primary-900/10 hover:bg-primary-50 dark:hover:bg-primary-900/20'
|
||||
)}
|
||||
aria-label={`${notification.title}. ${notification.message}${!isRead ? ' (unread)' : ''}`}
|
||||
>
|
||||
{/* Icon */}
|
||||
<div className={clsx('flex-shrink-0 w-8 h-8 rounded-full flex items-center justify-center', colorClass)}>
|
||||
<div className={clsx('flex-shrink-0 w-8 h-8 rounded-full flex items-center justify-center', colorClass)} aria-hidden="true">
|
||||
<Icon className="w-4 h-4" />
|
||||
</div>
|
||||
|
||||
@ -68,7 +69,7 @@ export function NotificationItem({ notification, onRead, onNavigate }: Notificat
|
||||
{notification.title}
|
||||
</p>
|
||||
{!isRead && (
|
||||
<span className="flex-shrink-0 w-2 h-2 bg-primary-500 rounded-full" />
|
||||
<span className="flex-shrink-0 w-2 h-2 bg-primary-500 rounded-full" aria-hidden="true" />
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-secondary-500 dark:text-secondary-400 line-clamp-2 mt-0.5">
|
||||
|
||||
@ -77,41 +77,52 @@ export function PermissionsMatrix({
|
||||
className="rounded-lg border border-gray-200 dark:border-gray-700"
|
||||
>
|
||||
{/* Category Header */}
|
||||
<div
|
||||
className="flex cursor-pointer items-center justify-between p-3 hover:bg-gray-50 dark:hover:bg-gray-700/50"
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full cursor-pointer items-center justify-between p-3 hover:bg-gray-50 dark:hover:bg-gray-700/50 focus:outline-none focus:ring-2 focus:ring-inset focus:ring-blue-500 rounded-t-lg"
|
||||
onClick={() => toggleCategory(category)}
|
||||
aria-expanded={isExpanded}
|
||||
aria-controls={`permissions-${category}`}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
<span
|
||||
className="flex-shrink-0 text-gray-400"
|
||||
aria-label={isExpanded ? 'Collapse' : 'Expand'}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{isExpanded ? (
|
||||
<ChevronDown className="h-5 w-5" />
|
||||
) : (
|
||||
<ChevronRight className="h-5 w-5" />
|
||||
)}
|
||||
</button>
|
||||
</span>
|
||||
{!readOnly && (
|
||||
<button
|
||||
type="button"
|
||||
<span
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleCategoryCheckboxClick(category);
|
||||
}}
|
||||
className={`flex h-5 w-5 flex-shrink-0 items-center justify-center rounded border ${
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleCategoryCheckboxClick(category);
|
||||
}
|
||||
}}
|
||||
role="checkbox"
|
||||
aria-checked={status === 'all' ? true : status === 'some' ? 'mixed' : false}
|
||||
aria-label={`Toggle all ${category} permissions`}
|
||||
tabIndex={0}
|
||||
className={`flex h-5 w-5 flex-shrink-0 items-center justify-center rounded border cursor-pointer focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-1 ${
|
||||
status === 'all'
|
||||
? 'border-blue-600 bg-blue-600 text-white'
|
||||
: status === 'some'
|
||||
? 'border-blue-600 bg-blue-100 text-blue-600 dark:bg-blue-900/20'
|
||||
: 'border-gray-300 bg-white dark:border-gray-600 dark:bg-gray-800'
|
||||
}`}
|
||||
aria-label={`Toggle all ${category} permissions`}
|
||||
>
|
||||
{status === 'all' && <Check className="h-3.5 w-3.5" />}
|
||||
{status === 'some' && <Minus className="h-3.5 w-3.5" />}
|
||||
</button>
|
||||
{status === 'all' && <Check className="h-3.5 w-3.5" aria-hidden="true" />}
|
||||
{status === 'some' && <Minus className="h-3.5 w-3.5" aria-hidden="true" />}
|
||||
</span>
|
||||
)}
|
||||
<span className="font-medium text-gray-900 dark:text-white">
|
||||
{getPermissionCategoryLabel(category)}
|
||||
@ -120,11 +131,16 @@ export function PermissionsMatrix({
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{selectedCount} / {categoryPerms.length}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* Permissions List */}
|
||||
{isExpanded && (
|
||||
<div className="border-t border-gray-200 bg-gray-50 p-3 dark:border-gray-700 dark:bg-gray-800/50">
|
||||
<div
|
||||
id={`permissions-${category}`}
|
||||
className="border-t border-gray-200 bg-gray-50 p-3 dark:border-gray-700 dark:bg-gray-800/50"
|
||||
role="group"
|
||||
aria-label={`${getPermissionCategoryLabel(category)} permissions`}
|
||||
>
|
||||
<div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{categoryPerms.map((perm) => {
|
||||
const isSelected = selectedPermissions.includes(perm.slug);
|
||||
|
||||
@ -28,6 +28,7 @@ export function RoleCard({ role, onDelete, isDeleting }: RoleCardProps) {
|
||||
? 'bg-purple-100 dark:bg-purple-900/20'
|
||||
: 'bg-blue-100 dark:bg-blue-900/20'
|
||||
}`}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<Shield
|
||||
className={`h-5 w-5 ${
|
||||
@ -41,8 +42,8 @@ export function RoleCard({ role, onDelete, isDeleting }: RoleCardProps) {
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="font-medium text-gray-900 dark:text-white">{role.name}</h3>
|
||||
{role.is_system && (
|
||||
<span>
|
||||
<Lock className="h-4 w-4 text-gray-400" />
|
||||
<span aria-label="System role (locked)" title="System role">
|
||||
<Lock className="h-4 w-4 text-gray-400" aria-hidden="true" />
|
||||
</span>
|
||||
)}
|
||||
{role.is_default && (
|
||||
@ -59,27 +60,27 @@ export function RoleCard({ role, onDelete, isDeleting }: RoleCardProps) {
|
||||
<div className="flex items-center gap-1">
|
||||
<Link
|
||||
to={`/dashboard/rbac/roles/${role.id}`}
|
||||
className="rounded-lg p-2 text-gray-400 hover:bg-gray-100 hover:text-gray-600 dark:hover:bg-gray-700 dark:hover:text-gray-300"
|
||||
title="View details"
|
||||
className="rounded-lg p-2 text-gray-400 hover:bg-gray-100 hover:text-gray-600 dark:hover:bg-gray-700 dark:hover:text-gray-300 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
aria-label={`View details for role ${role.name}`}
|
||||
>
|
||||
<Users className="h-4 w-4" />
|
||||
<Users className="h-4 w-4" aria-hidden="true" />
|
||||
</Link>
|
||||
{!role.is_system && (
|
||||
<>
|
||||
<Link
|
||||
to={`/dashboard/rbac/roles/${role.id}/edit`}
|
||||
className="rounded-lg p-2 text-gray-400 hover:bg-gray-100 hover:text-gray-600 dark:hover:bg-gray-700 dark:hover:text-gray-300"
|
||||
title="Edit role"
|
||||
className="rounded-lg p-2 text-gray-400 hover:bg-gray-100 hover:text-gray-600 dark:hover:bg-gray-700 dark:hover:text-gray-300 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
aria-label={`Edit role ${role.name}`}
|
||||
>
|
||||
<Edit className="h-4 w-4" />
|
||||
<Edit className="h-4 w-4" aria-hidden="true" />
|
||||
</Link>
|
||||
<button
|
||||
onClick={handleDelete}
|
||||
disabled={isDeleting}
|
||||
className="rounded-lg p-2 text-gray-400 hover:bg-red-50 hover:text-red-600 disabled:cursor-not-allowed disabled:opacity-50 dark:hover:bg-red-900/20 dark:hover:text-red-400"
|
||||
title="Delete role"
|
||||
className="rounded-lg p-2 text-gray-400 hover:bg-red-50 hover:text-red-600 disabled:cursor-not-allowed disabled:opacity-50 dark:hover:bg-red-900/20 dark:hover:text-red-400 focus:outline-none focus:ring-2 focus:ring-red-500"
|
||||
aria-label={`Delete role ${role.name}`}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
<Trash2 className="h-4 w-4" aria-hidden="true" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
@ -90,24 +90,27 @@ export function RoleForm({ role, permissions, onSubmit, isLoading }: RoleFormPro
|
||||
</h3>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
<label htmlFor="role-name" className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Role Name *
|
||||
</label>
|
||||
<input
|
||||
id="role-name"
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="e.g., Sales Manager"
|
||||
className="w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-gray-900 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||
className="w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-gray-900 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||
required
|
||||
aria-required="true"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
<label htmlFor="role-slug" className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Slug {!isEditing && '*'}
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
id="role-slug"
|
||||
type="text"
|
||||
value={slug}
|
||||
onChange={(e) => {
|
||||
@ -117,31 +120,34 @@ export function RoleForm({ role, permissions, onSubmit, isLoading }: RoleFormPro
|
||||
placeholder="e.g., sales-manager"
|
||||
disabled={isEditing}
|
||||
className={clsx(
|
||||
'w-full rounded-lg border border-gray-300 bg-white px-3 py-2 font-mono text-sm text-gray-900 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white',
|
||||
'w-full rounded-lg border border-gray-300 bg-white px-3 py-2 font-mono text-sm text-gray-900 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white',
|
||||
isEditing && 'cursor-not-allowed bg-gray-100 dark:bg-gray-800'
|
||||
)}
|
||||
required={!isEditing}
|
||||
aria-required={!isEditing}
|
||||
aria-describedby={isEditing ? 'slug-description' : undefined}
|
||||
/>
|
||||
{!isEditing && (
|
||||
<span className="absolute right-3 top-1/2 -translate-y-1/2 text-xs text-gray-400">
|
||||
<span className="absolute right-3 top-1/2 -translate-y-1/2 text-xs text-gray-400" aria-hidden="true">
|
||||
{autoSlug ? 'Auto' : 'Manual'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{isEditing && (
|
||||
<p className="mt-1 text-xs text-gray-500">Slug cannot be changed after creation</p>
|
||||
<p id="slug-description" className="mt-1 text-xs text-gray-500">Slug cannot be changed after creation</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="sm:col-span-2">
|
||||
<label className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
<label htmlFor="role-description" className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Description
|
||||
</label>
|
||||
<textarea
|
||||
id="role-description"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="Describe what this role is for..."
|
||||
rows={3}
|
||||
className="w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-gray-900 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||
className="w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-gray-900 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@ -160,14 +166,14 @@ export function RoleForm({ role, permissions, onSubmit, isLoading }: RoleFormPro
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSelectAll}
|
||||
className="rounded-lg border border-gray-300 px-3 py-1.5 text-sm font-medium text-gray-700 hover:bg-gray-50 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-700"
|
||||
className="rounded-lg border border-gray-300 px-3 py-1.5 text-sm font-medium text-gray-700 hover:bg-gray-50 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
Select All
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClearAll}
|
||||
className="rounded-lg border border-gray-300 px-3 py-1.5 text-sm font-medium text-gray-700 hover:bg-gray-50 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-700"
|
||||
className="rounded-lg border border-gray-300 px-3 py-1.5 text-sm font-medium text-gray-700 hover:bg-gray-50 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
Clear All
|
||||
</button>
|
||||
@ -186,15 +192,16 @@ export function RoleForm({ role, permissions, onSubmit, isLoading }: RoleFormPro
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate('/dashboard/rbac/roles')}
|
||||
className="rounded-lg px-4 py-2 text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700"
|
||||
className="rounded-lg px-4 py-2 text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!isValid || isLoading}
|
||||
aria-disabled={!isValid || isLoading}
|
||||
className={clsx(
|
||||
'rounded-lg px-4 py-2 font-medium',
|
||||
'rounded-lg px-4 py-2 font-medium focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2',
|
||||
isValid && !isLoading
|
||||
? 'bg-blue-600 text-white hover:bg-blue-700'
|
||||
: 'cursor-not-allowed bg-gray-200 text-gray-400'
|
||||
|
||||
@ -77,13 +77,14 @@ export function DashboardLayout() {
|
||||
<h1 className="text-xl font-bold text-primary-600">Template SaaS</h1>
|
||||
<button
|
||||
onClick={toggleSidebar}
|
||||
className="lg:hidden p-2 rounded-lg hover:bg-secondary-100 dark:hover:bg-secondary-700"
|
||||
className="lg:hidden p-2 rounded-lg hover:bg-secondary-100 dark:hover:bg-secondary-700 focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||
aria-label="Close sidebar"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
<X className="w-5 h-5" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<nav className="p-4 space-y-1">
|
||||
<nav className="p-4 space-y-1" aria-label="Main navigation">
|
||||
{/* Regular navigation */}
|
||||
{navigation.map((item) => (
|
||||
<NavLink
|
||||
@ -92,14 +93,14 @@ export function DashboardLayout() {
|
||||
end={item.href === '/dashboard'}
|
||||
className={({ isActive }) =>
|
||||
clsx(
|
||||
'flex items-center gap-3 px-4 py-2.5 rounded-lg text-sm font-medium transition-colors',
|
||||
'flex items-center gap-3 px-4 py-2.5 rounded-lg text-sm font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-primary-500',
|
||||
isActive
|
||||
? 'bg-primary-50 text-primary-600 dark:bg-primary-900/20 dark:text-primary-400'
|
||||
: 'text-secondary-600 hover:bg-secondary-100 dark:text-secondary-400 dark:hover:bg-secondary-700'
|
||||
)
|
||||
}
|
||||
>
|
||||
<item.icon className="w-5 h-5" />
|
||||
<item.icon className="w-5 h-5" aria-hidden="true" />
|
||||
{item.name}
|
||||
</NavLink>
|
||||
))}
|
||||
@ -109,7 +110,7 @@ export function DashboardLayout() {
|
||||
<>
|
||||
<div className="pt-4 mt-4 border-t border-secondary-200 dark:border-secondary-700">
|
||||
<div className="flex items-center gap-2 px-4 py-2 text-xs font-semibold text-secondary-500 uppercase tracking-wider">
|
||||
<Shield className="w-4 h-4" />
|
||||
<Shield className="w-4 h-4" aria-hidden="true" />
|
||||
Superadmin
|
||||
</div>
|
||||
</div>
|
||||
@ -119,14 +120,14 @@ export function DashboardLayout() {
|
||||
to={item.href}
|
||||
className={({ isActive }) =>
|
||||
clsx(
|
||||
'flex items-center gap-3 px-4 py-2.5 rounded-lg text-sm font-medium transition-colors',
|
||||
'flex items-center gap-3 px-4 py-2.5 rounded-lg text-sm font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-purple-500',
|
||||
isActive
|
||||
? 'bg-purple-50 text-purple-600 dark:bg-purple-900/20 dark:text-purple-400'
|
||||
: 'text-secondary-600 hover:bg-secondary-100 dark:text-secondary-400 dark:hover:bg-secondary-700'
|
||||
)
|
||||
}
|
||||
>
|
||||
<item.icon className="w-5 h-5" />
|
||||
<item.icon className="w-5 h-5" aria-hidden="true" />
|
||||
{item.name}
|
||||
</NavLink>
|
||||
))}
|
||||
@ -140,6 +141,7 @@ export function DashboardLayout() {
|
||||
<div
|
||||
className="fixed inset-0 z-40 bg-black/50 lg:hidden"
|
||||
onClick={toggleSidebar}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
|
||||
@ -149,9 +151,10 @@ export function DashboardLayout() {
|
||||
<header className="sticky top-0 z-30 h-16 bg-white dark:bg-secondary-800 border-b border-secondary-200 dark:border-secondary-700 px-4 lg:px-6 flex items-center justify-between">
|
||||
<button
|
||||
onClick={toggleSidebar}
|
||||
className="lg:hidden p-2 rounded-lg hover:bg-secondary-100 dark:hover:bg-secondary-700"
|
||||
className="lg:hidden p-2 rounded-lg hover:bg-secondary-100 dark:hover:bg-secondary-700 focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||
aria-label="Open sidebar menu"
|
||||
>
|
||||
<Menu className="w-5 h-5" />
|
||||
<Menu className="w-5 h-5" aria-hidden="true" />
|
||||
</button>
|
||||
|
||||
<div className="flex items-center gap-4 ml-auto">
|
||||
@ -162,9 +165,12 @@ export function DashboardLayout() {
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setUserMenuOpen(!userMenuOpen)}
|
||||
className="flex items-center gap-3 p-2 rounded-lg hover:bg-secondary-100 dark:hover:bg-secondary-700"
|
||||
className="flex items-center gap-3 p-2 rounded-lg hover:bg-secondary-100 dark:hover:bg-secondary-700 focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||
aria-expanded={userMenuOpen}
|
||||
aria-haspopup="true"
|
||||
aria-label="User menu"
|
||||
>
|
||||
<div className="w-8 h-8 bg-primary-100 dark:bg-primary-900 rounded-full flex items-center justify-center">
|
||||
<div className="w-8 h-8 bg-primary-100 dark:bg-primary-900 rounded-full flex items-center justify-center" aria-hidden="true">
|
||||
<span className="text-sm font-medium text-primary-600 dark:text-primary-400">
|
||||
{user?.first_name?.[0]}{user?.last_name?.[0]}
|
||||
</span>
|
||||
@ -175,7 +181,7 @@ export function DashboardLayout() {
|
||||
</p>
|
||||
<p className="text-xs text-secondary-500">{user?.role}</p>
|
||||
</div>
|
||||
<ChevronDown className="w-4 h-4 text-secondary-400" />
|
||||
<ChevronDown className="w-4 h-4 text-secondary-400" aria-hidden="true" />
|
||||
</button>
|
||||
|
||||
{userMenuOpen && (
|
||||
@ -183,13 +189,20 @@ export function DashboardLayout() {
|
||||
<div
|
||||
className="fixed inset-0 z-40"
|
||||
onClick={() => setUserMenuOpen(false)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div className="absolute right-0 mt-2 w-48 bg-white dark:bg-secondary-800 rounded-lg shadow-lg border border-secondary-200 dark:border-secondary-700 py-1 z-50">
|
||||
<div
|
||||
className="absolute right-0 mt-2 w-48 bg-white dark:bg-secondary-800 rounded-lg shadow-lg border border-secondary-200 dark:border-secondary-700 py-1 z-50"
|
||||
role="menu"
|
||||
aria-orientation="vertical"
|
||||
aria-label="User menu options"
|
||||
>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="w-full flex items-center gap-2 px-4 py-2 text-sm text-red-600 hover:bg-secondary-100 dark:hover:bg-secondary-700"
|
||||
className="w-full flex items-center gap-2 px-4 py-2 text-sm text-red-600 hover:bg-secondary-100 dark:hover:bg-secondary-700 focus:outline-none focus:bg-secondary-100 dark:focus:bg-secondary-700"
|
||||
role="menuitem"
|
||||
>
|
||||
<LogOut className="w-4 h-4" />
|
||||
<LogOut className="w-4 h-4" aria-hidden="true" />
|
||||
Sign out
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@ -21,5 +21,6 @@
|
||||
"@/*": ["src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src"]
|
||||
"include": ["src"],
|
||||
"exclude": ["src/__tests__", "src/**/*.test.ts", "src/**/*.test.tsx", "src/**/*.spec.ts", "src/**/*.spec.tsx"]
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user