[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">
|
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3">
|
||||||
{/* Search input */}
|
{/* Search input */}
|
||||||
{onSearchChange && (
|
{onSearchChange && (
|
||||||
<form onSubmit={handleSearchSubmit} className="relative flex-1 max-w-md">
|
<form onSubmit={handleSearchSubmit} className="relative flex-1 max-w-md" role="search">
|
||||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-secondary-400" />
|
<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
|
<input
|
||||||
|
id="audit-search"
|
||||||
type="text"
|
type="text"
|
||||||
value={localSearch}
|
value={localSearch}
|
||||||
onChange={(e) => setLocalSearch(e.target.value)}
|
onChange={(e) => setLocalSearch(e.target.value)}
|
||||||
@ -89,9 +91,10 @@ export function AuditFilters({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleSearchClear}
|
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>
|
</button>
|
||||||
)}
|
)}
|
||||||
</form>
|
</form>
|
||||||
@ -101,16 +104,18 @@ export function AuditFilters({
|
|||||||
<button
|
<button
|
||||||
onClick={() => setIsOpen(!isOpen)}
|
onClick={() => setIsOpen(!isOpen)}
|
||||||
className={clsx(
|
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
|
isOpen || activeFiltersCount > 0
|
||||||
? 'bg-primary-100 text-primary-700 dark:bg-primary-900/30 dark:text-primary-400'
|
? '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'
|
: '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
|
Filters
|
||||||
{activeFiltersCount > 0 && (
|
{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}
|
{activeFiltersCount}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@ -119,9 +124,10 @@ export function AuditFilters({
|
|||||||
{activeFiltersCount > 0 && (
|
{activeFiltersCount > 0 && (
|
||||||
<button
|
<button
|
||||||
onClick={clearFilters}
|
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
|
Clear all
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
@ -130,14 +136,15 @@ export function AuditFilters({
|
|||||||
|
|
||||||
{/* Filter panel */}
|
{/* Filter panel */}
|
||||||
{isOpen && (
|
{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">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-4">
|
||||||
{/* Action filter */}
|
{/* Action filter */}
|
||||||
<div>
|
<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
|
Action
|
||||||
</label>
|
</label>
|
||||||
<select
|
<select
|
||||||
|
id="audit-filter-action"
|
||||||
value={filters.action || ''}
|
value={filters.action || ''}
|
||||||
onChange={(e) => handleChange('action', e.target.value)}
|
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"
|
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 */}
|
{/* Entity type filter */}
|
||||||
<div>
|
<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
|
Entity Type
|
||||||
</label>
|
</label>
|
||||||
<select
|
<select
|
||||||
|
id="audit-filter-entity"
|
||||||
value={filters.entity_type || ''}
|
value={filters.entity_type || ''}
|
||||||
onChange={(e) => handleChange('entity_type', e.target.value)}
|
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"
|
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 */}
|
{/* User filter */}
|
||||||
{users.length > 0 && (
|
{users.length > 0 && (
|
||||||
<div>
|
<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
|
User
|
||||||
</label>
|
</label>
|
||||||
<select
|
<select
|
||||||
|
id="audit-filter-user"
|
||||||
value={filters.user_id || ''}
|
value={filters.user_id || ''}
|
||||||
onChange={(e) => handleChange('user_id', e.target.value)}
|
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"
|
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 */}
|
{/* From date filter */}
|
||||||
<div>
|
<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
|
From Date
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
|
id="audit-filter-from-date"
|
||||||
type="date"
|
type="date"
|
||||||
value={filters.from_date || ''}
|
value={filters.from_date || ''}
|
||||||
onChange={(e) => handleChange('from_date', e.target.value)}
|
onChange={(e) => handleChange('from_date', e.target.value)}
|
||||||
@ -206,10 +216,11 @@ export function AuditFilters({
|
|||||||
|
|
||||||
{/* To date filter */}
|
{/* To date filter */}
|
||||||
<div>
|
<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
|
To Date
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
|
id="audit-filter-to-date"
|
||||||
type="date"
|
type="date"
|
||||||
value={filters.to_date || ''}
|
value={filters.to_date || ''}
|
||||||
onChange={(e) => handleChange('to_date', e.target.value)}
|
onChange={(e) => handleChange('to_date', e.target.value)}
|
||||||
@ -263,8 +274,12 @@ function FilterBadge({ label, onRemove }: { label: string; onRemove: () => void
|
|||||||
return (
|
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">
|
<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}
|
{label}
|
||||||
<button onClick={onRemove} className="hover:text-primary-900 dark:hover:text-primary-200">
|
<button
|
||||||
<X className="w-3 h-3" />
|
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>
|
</button>
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -72,17 +72,25 @@ export function GoalProgressBar({
|
|||||||
{/* Progress Bar Container */}
|
{/* Progress Bar Container */}
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
{/* Background Bar */}
|
{/* 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 */}
|
{/* Progress Fill */}
|
||||||
<div
|
<div
|
||||||
className={`${sizeClasses[size]} rounded-full transition-all duration-500 ${getProgressColor(percentage)}`}
|
className={`${sizeClasses[size]} rounded-full transition-all duration-500 ${getProgressColor(percentage)}`}
|
||||||
style={{ width: `${percentage}%` }}
|
style={{ width: `${percentage}%` }}
|
||||||
|
aria-hidden="true"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Milestones */}
|
{/* Milestones */}
|
||||||
{milestones && milestones.length > 0 && (
|
{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) => (
|
{milestones.map((milestone, index) => (
|
||||||
<div
|
<div
|
||||||
key={index}
|
key={index}
|
||||||
@ -104,7 +112,7 @@ export function GoalProgressBar({
|
|||||||
|
|
||||||
{/* Milestone Labels */}
|
{/* Milestone Labels */}
|
||||||
{milestones && milestones.length > 0 && size === 'lg' && (
|
{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) => (
|
{milestones.map((milestone, index) => (
|
||||||
<span
|
<span
|
||||||
key={index}
|
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 (
|
return (
|
||||||
<div>
|
<div role="treeitem" aria-expanded={node.children && node.children.length > 0 ? true : undefined}>
|
||||||
<div
|
<button
|
||||||
className="flex items-center py-2 px-4 hover:bg-gray-50 border-b border-gray-100 cursor-pointer"
|
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` }}
|
style={{ paddingLeft: `${indent + 16}px` }}
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
aria-label={`View details for ${userName}, ${node.rank?.name || 'No Rank'}, Level ${node.depth}`}
|
||||||
>
|
>
|
||||||
{depth > 0 && (
|
{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-1 flex items-center justify-between">
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<div
|
<div
|
||||||
className="w-8 h-8 rounded-full flex items-center justify-center text-white text-sm font-medium mr-3"
|
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' }}
|
style={{ backgroundColor: node.rank?.color || '#6B7280' }}
|
||||||
|
aria-hidden="true"
|
||||||
>
|
>
|
||||||
{node.user?.firstName?.charAt(0) || node.user?.email?.charAt(0) || '?'}
|
{node.user?.firstName?.charAt(0) || node.user?.email?.charAt(0) || '?'}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-medium text-gray-900">
|
<p className="text-sm font-medium text-gray-900">
|
||||||
{node.user?.firstName && node.user?.lastName
|
{userName}
|
||||||
? `${node.user.firstName} ${node.user.lastName}`
|
|
||||||
: node.user?.email || 'Unknown'}
|
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-gray-500">
|
<p className="text-xs text-gray-500">
|
||||||
{node.rank?.name || 'No Rank'} | Level {node.depth}
|
{node.rank?.name || 'No Rank'} | Level {node.depth}
|
||||||
@ -69,23 +82,28 @@ function TreeNodeItem({ node, depth, onNodeClick }: TreeNodeItemProps) {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</button>
|
||||||
{node.children?.map((child) => (
|
{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} />
|
<TreeNodeItem key={child.id} node={child} depth={depth + 1} onNodeClick={onNodeClick} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function NetworkTree({ tree, maxHeight = '600px', onNodeClick }: NetworkTreeProps) {
|
export function NetworkTree({ tree, maxHeight = '600px', onNodeClick }: NetworkTreeProps) {
|
||||||
if (!tree) {
|
if (!tree) {
|
||||||
return (
|
return (
|
||||||
<div className="text-center py-10">
|
<div className="text-center py-10" role="status">
|
||||||
<svg
|
<svg
|
||||||
className="mx-auto h-12 w-12 text-gray-400"
|
className="mx-auto h-12 w-12 text-gray-400"
|
||||||
fill="none"
|
fill="none"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
|
aria-hidden="true"
|
||||||
>
|
>
|
||||||
<path
|
<path
|
||||||
strokeLinecap="round"
|
strokeLinecap="round"
|
||||||
@ -100,7 +118,7 @@ export function NetworkTree({ tree, maxHeight = '600px', onNodeClick }: NetworkT
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
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} />
|
<TreeNodeItem node={tree} depth={0} onNodeClick={onNodeClick} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -15,8 +15,9 @@ export function NotificationBell() {
|
|||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
onClick={() => setIsDrawerOpen(true)}
|
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-label={`Notifications${hasUnread ? ` (${unreadCount} unread)` : ''}`}
|
||||||
|
aria-haspopup="dialog"
|
||||||
>
|
>
|
||||||
<Bell
|
<Bell
|
||||||
className={clsx(
|
className={clsx(
|
||||||
@ -25,9 +26,10 @@ export function NotificationBell() {
|
|||||||
? 'text-primary-600 dark:text-primary-400'
|
? 'text-primary-600 dark:text-primary-400'
|
||||||
: 'text-secondary-600 dark:text-secondary-400'
|
: 'text-secondary-600 dark:text-secondary-400'
|
||||||
)}
|
)}
|
||||||
|
aria-hidden="true"
|
||||||
/>
|
/>
|
||||||
{hasUnread && (
|
{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}
|
{unreadCount > 99 ? '99+' : unreadCount}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -38,11 +38,15 @@ export function NotificationDrawer({ isOpen, onClose }: NotificationDrawerProps)
|
|||||||
<div
|
<div
|
||||||
className="fixed inset-0 z-40 bg-black/20 dark:bg-black/40"
|
className="fixed inset-0 z-40 bg-black/20 dark:bg-black/40"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
|
aria-hidden="true"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Drawer */}
|
{/* Drawer */}
|
||||||
<div
|
<aside
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label="Notifications panel"
|
||||||
className={clsx(
|
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',
|
'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'
|
isOpen ? 'translate-x-0' : 'translate-x-full'
|
||||||
@ -51,8 +55,8 @@ export function NotificationDrawer({ isOpen, onClose }: NotificationDrawerProps)
|
|||||||
{/* Header */}
|
{/* 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 justify-between h-16 px-4 border-b border-secondary-200 dark:border-secondary-700">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Bell className="w-5 h-5 text-secondary-600 dark:text-secondary-400" />
|
<Bell className="w-5 h-5 text-secondary-600 dark:text-secondary-400" aria-hidden="true" />
|
||||||
<h2 className="text-lg font-semibold text-secondary-900 dark:text-secondary-100">
|
<h2 id="notifications-drawer-title" className="text-lg font-semibold text-secondary-900 dark:text-secondary-100">
|
||||||
Notifications
|
Notifications
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
@ -61,21 +65,23 @@ export function NotificationDrawer({ isOpen, onClose }: NotificationDrawerProps)
|
|||||||
<button
|
<button
|
||||||
onClick={handleMarkAllAsRead}
|
onClick={handleMarkAllAsRead}
|
||||||
disabled={markAllAsRead.isPending}
|
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 ? (
|
{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
|
Mark all read
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
onClick={onClose}
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -83,12 +89,12 @@ export function NotificationDrawer({ isOpen, onClose }: NotificationDrawerProps)
|
|||||||
{/* Content */}
|
{/* Content */}
|
||||||
<div className="h-[calc(100%-4rem)] overflow-y-auto">
|
<div className="h-[calc(100%-4rem)] overflow-y-auto">
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="flex items-center justify-center h-32">
|
<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" />
|
<Loader2 className="w-6 h-6 animate-spin text-primary-500" aria-hidden="true" />
|
||||||
</div>
|
</div>
|
||||||
) : notifications.length === 0 ? (
|
) : notifications.length === 0 ? (
|
||||||
<div className="flex flex-col items-center justify-center h-64 text-center px-4">
|
<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" />
|
<Bell className="w-8 h-8 text-secondary-400" />
|
||||||
</div>
|
</div>
|
||||||
<p className="text-secondary-600 dark:text-secondary-400 font-medium">
|
<p className="text-secondary-600 dark:text-secondary-400 font-medium">
|
||||||
@ -99,19 +105,20 @@ export function NotificationDrawer({ isOpen, onClose }: NotificationDrawerProps)
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</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) => (
|
{notifications.map((notification) => (
|
||||||
|
<li key={notification.id}>
|
||||||
<NotificationItem
|
<NotificationItem
|
||||||
key={notification.id}
|
|
||||||
notification={notification}
|
notification={notification}
|
||||||
onRead={handleMarkAsRead}
|
onRead={handleMarkAsRead}
|
||||||
onNavigate={handleNavigate}
|
onNavigate={handleNavigate}
|
||||||
/>
|
/>
|
||||||
|
</li>
|
||||||
))}
|
))}
|
||||||
</div>
|
</ul>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</aside>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -45,14 +45,15 @@ export function NotificationItem({ notification, onRead, onNavigate }: Notificat
|
|||||||
<button
|
<button
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
className={clsx(
|
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
|
isRead
|
||||||
? 'bg-transparent hover:bg-secondary-50 dark:hover:bg-secondary-800'
|
? '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'
|
: '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 */}
|
{/* 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" />
|
<Icon className="w-4 h-4" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -68,7 +69,7 @@ export function NotificationItem({ notification, onRead, onNavigate }: Notificat
|
|||||||
{notification.title}
|
{notification.title}
|
||||||
</p>
|
</p>
|
||||||
{!isRead && (
|
{!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>
|
</div>
|
||||||
<p className="text-xs text-secondary-500 dark:text-secondary-400 line-clamp-2 mt-0.5">
|
<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"
|
className="rounded-lg border border-gray-200 dark:border-gray-700"
|
||||||
>
|
>
|
||||||
{/* Category Header */}
|
{/* Category Header */}
|
||||||
<div
|
|
||||||
className="flex cursor-pointer items-center justify-between p-3 hover:bg-gray-50 dark:hover:bg-gray-700/50"
|
|
||||||
onClick={() => toggleCategory(category)}
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<button
|
<button
|
||||||
type="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">
|
||||||
|
<span
|
||||||
className="flex-shrink-0 text-gray-400"
|
className="flex-shrink-0 text-gray-400"
|
||||||
aria-label={isExpanded ? 'Collapse' : 'Expand'}
|
aria-hidden="true"
|
||||||
>
|
>
|
||||||
{isExpanded ? (
|
{isExpanded ? (
|
||||||
<ChevronDown className="h-5 w-5" />
|
<ChevronDown className="h-5 w-5" />
|
||||||
) : (
|
) : (
|
||||||
<ChevronRight className="h-5 w-5" />
|
<ChevronRight className="h-5 w-5" />
|
||||||
)}
|
)}
|
||||||
</button>
|
</span>
|
||||||
{!readOnly && (
|
{!readOnly && (
|
||||||
<button
|
<span
|
||||||
type="button"
|
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
handleCategoryCheckboxClick(category);
|
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'
|
status === 'all'
|
||||||
? 'border-blue-600 bg-blue-600 text-white'
|
? 'border-blue-600 bg-blue-600 text-white'
|
||||||
: status === 'some'
|
: status === 'some'
|
||||||
? 'border-blue-600 bg-blue-100 text-blue-600 dark:bg-blue-900/20'
|
? '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'
|
: '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 === 'all' && <Check className="h-3.5 w-3.5" aria-hidden="true" />}
|
||||||
{status === 'some' && <Minus className="h-3.5 w-3.5" />}
|
{status === 'some' && <Minus className="h-3.5 w-3.5" aria-hidden="true" />}
|
||||||
</button>
|
</span>
|
||||||
)}
|
)}
|
||||||
<span className="font-medium text-gray-900 dark:text-white">
|
<span className="font-medium text-gray-900 dark:text-white">
|
||||||
{getPermissionCategoryLabel(category)}
|
{getPermissionCategoryLabel(category)}
|
||||||
@ -120,11 +131,16 @@ export function PermissionsMatrix({
|
|||||||
<span className="text-sm text-gray-500 dark:text-gray-400">
|
<span className="text-sm text-gray-500 dark:text-gray-400">
|
||||||
{selectedCount} / {categoryPerms.length}
|
{selectedCount} / {categoryPerms.length}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</button>
|
||||||
|
|
||||||
{/* Permissions List */}
|
{/* Permissions List */}
|
||||||
{isExpanded && (
|
{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">
|
<div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
{categoryPerms.map((perm) => {
|
{categoryPerms.map((perm) => {
|
||||||
const isSelected = selectedPermissions.includes(perm.slug);
|
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-purple-100 dark:bg-purple-900/20'
|
||||||
: 'bg-blue-100 dark:bg-blue-900/20'
|
: 'bg-blue-100 dark:bg-blue-900/20'
|
||||||
}`}
|
}`}
|
||||||
|
aria-hidden="true"
|
||||||
>
|
>
|
||||||
<Shield
|
<Shield
|
||||||
className={`h-5 w-5 ${
|
className={`h-5 w-5 ${
|
||||||
@ -41,8 +42,8 @@ export function RoleCard({ role, onDelete, isDeleting }: RoleCardProps) {
|
|||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<h3 className="font-medium text-gray-900 dark:text-white">{role.name}</h3>
|
<h3 className="font-medium text-gray-900 dark:text-white">{role.name}</h3>
|
||||||
{role.is_system && (
|
{role.is_system && (
|
||||||
<span>
|
<span aria-label="System role (locked)" title="System role">
|
||||||
<Lock className="h-4 w-4 text-gray-400" />
|
<Lock className="h-4 w-4 text-gray-400" aria-hidden="true" />
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{role.is_default && (
|
{role.is_default && (
|
||||||
@ -59,27 +60,27 @@ export function RoleCard({ role, onDelete, isDeleting }: RoleCardProps) {
|
|||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<Link
|
<Link
|
||||||
to={`/dashboard/rbac/roles/${role.id}`}
|
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"
|
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"
|
||||||
title="View details"
|
aria-label={`View details for role ${role.name}`}
|
||||||
>
|
>
|
||||||
<Users className="h-4 w-4" />
|
<Users className="h-4 w-4" aria-hidden="true" />
|
||||||
</Link>
|
</Link>
|
||||||
{!role.is_system && (
|
{!role.is_system && (
|
||||||
<>
|
<>
|
||||||
<Link
|
<Link
|
||||||
to={`/dashboard/rbac/roles/${role.id}/edit`}
|
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"
|
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"
|
||||||
title="Edit role"
|
aria-label={`Edit role ${role.name}`}
|
||||||
>
|
>
|
||||||
<Edit className="h-4 w-4" />
|
<Edit className="h-4 w-4" aria-hidden="true" />
|
||||||
</Link>
|
</Link>
|
||||||
<button
|
<button
|
||||||
onClick={handleDelete}
|
onClick={handleDelete}
|
||||||
disabled={isDeleting}
|
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"
|
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"
|
||||||
title="Delete role"
|
aria-label={`Delete role ${role.name}`}
|
||||||
>
|
>
|
||||||
<Trash2 className="h-4 w-4" />
|
<Trash2 className="h-4 w-4" aria-hidden="true" />
|
||||||
</button>
|
</button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -90,24 +90,27 @@ export function RoleForm({ role, permissions, onSubmit, isLoading }: RoleFormPro
|
|||||||
</h3>
|
</h3>
|
||||||
<div className="grid gap-4 sm:grid-cols-2">
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
<div>
|
<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 *
|
Role Name *
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
|
id="role-name"
|
||||||
type="text"
|
type="text"
|
||||||
value={name}
|
value={name}
|
||||||
onChange={(e) => setName(e.target.value)}
|
onChange={(e) => setName(e.target.value)}
|
||||||
placeholder="e.g., Sales Manager"
|
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
|
required
|
||||||
|
aria-required="true"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<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 && '*'}
|
Slug {!isEditing && '*'}
|
||||||
</label>
|
</label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<input
|
<input
|
||||||
|
id="role-slug"
|
||||||
type="text"
|
type="text"
|
||||||
value={slug}
|
value={slug}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
@ -117,31 +120,34 @@ export function RoleForm({ role, permissions, onSubmit, isLoading }: RoleFormPro
|
|||||||
placeholder="e.g., sales-manager"
|
placeholder="e.g., sales-manager"
|
||||||
disabled={isEditing}
|
disabled={isEditing}
|
||||||
className={clsx(
|
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'
|
isEditing && 'cursor-not-allowed bg-gray-100 dark:bg-gray-800'
|
||||||
)}
|
)}
|
||||||
required={!isEditing}
|
required={!isEditing}
|
||||||
|
aria-required={!isEditing}
|
||||||
|
aria-describedby={isEditing ? 'slug-description' : undefined}
|
||||||
/>
|
/>
|
||||||
{!isEditing && (
|
{!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'}
|
{autoSlug ? 'Auto' : 'Manual'}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{isEditing && (
|
{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>
|
||||||
<div className="sm:col-span-2">
|
<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
|
Description
|
||||||
</label>
|
</label>
|
||||||
<textarea
|
<textarea
|
||||||
|
id="role-description"
|
||||||
value={description}
|
value={description}
|
||||||
onChange={(e) => setDescription(e.target.value)}
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
placeholder="Describe what this role is for..."
|
placeholder="Describe what this role is for..."
|
||||||
rows={3}
|
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>
|
||||||
</div>
|
</div>
|
||||||
@ -160,14 +166,14 @@ export function RoleForm({ role, permissions, onSubmit, isLoading }: RoleFormPro
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleSelectAll}
|
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
|
Select All
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleClearAll}
|
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
|
Clear All
|
||||||
</button>
|
</button>
|
||||||
@ -186,15 +192,16 @@ export function RoleForm({ role, permissions, onSubmit, isLoading }: RoleFormPro
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => navigate('/dashboard/rbac/roles')}
|
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
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={!isValid || isLoading}
|
disabled={!isValid || isLoading}
|
||||||
|
aria-disabled={!isValid || isLoading}
|
||||||
className={clsx(
|
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
|
isValid && !isLoading
|
||||||
? 'bg-blue-600 text-white hover:bg-blue-700'
|
? 'bg-blue-600 text-white hover:bg-blue-700'
|
||||||
: 'cursor-not-allowed bg-gray-200 text-gray-400'
|
: '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>
|
<h1 className="text-xl font-bold text-primary-600">Template SaaS</h1>
|
||||||
<button
|
<button
|
||||||
onClick={toggleSidebar}
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<nav className="p-4 space-y-1">
|
<nav className="p-4 space-y-1" aria-label="Main navigation">
|
||||||
{/* Regular navigation */}
|
{/* Regular navigation */}
|
||||||
{navigation.map((item) => (
|
{navigation.map((item) => (
|
||||||
<NavLink
|
<NavLink
|
||||||
@ -92,14 +93,14 @@ export function DashboardLayout() {
|
|||||||
end={item.href === '/dashboard'}
|
end={item.href === '/dashboard'}
|
||||||
className={({ isActive }) =>
|
className={({ isActive }) =>
|
||||||
clsx(
|
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
|
isActive
|
||||||
? 'bg-primary-50 text-primary-600 dark:bg-primary-900/20 dark:text-primary-400'
|
? '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'
|
: '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}
|
{item.name}
|
||||||
</NavLink>
|
</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="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">
|
<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
|
Superadmin
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -119,14 +120,14 @@ export function DashboardLayout() {
|
|||||||
to={item.href}
|
to={item.href}
|
||||||
className={({ isActive }) =>
|
className={({ isActive }) =>
|
||||||
clsx(
|
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
|
isActive
|
||||||
? 'bg-purple-50 text-purple-600 dark:bg-purple-900/20 dark:text-purple-400'
|
? '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'
|
: '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}
|
{item.name}
|
||||||
</NavLink>
|
</NavLink>
|
||||||
))}
|
))}
|
||||||
@ -140,6 +141,7 @@ export function DashboardLayout() {
|
|||||||
<div
|
<div
|
||||||
className="fixed inset-0 z-40 bg-black/50 lg:hidden"
|
className="fixed inset-0 z-40 bg-black/50 lg:hidden"
|
||||||
onClick={toggleSidebar}
|
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">
|
<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
|
<button
|
||||||
onClick={toggleSidebar}
|
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>
|
</button>
|
||||||
|
|
||||||
<div className="flex items-center gap-4 ml-auto">
|
<div className="flex items-center gap-4 ml-auto">
|
||||||
@ -162,9 +165,12 @@ export function DashboardLayout() {
|
|||||||
<div className="relative">
|
<div className="relative">
|
||||||
<button
|
<button
|
||||||
onClick={() => setUserMenuOpen(!userMenuOpen)}
|
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">
|
<span className="text-sm font-medium text-primary-600 dark:text-primary-400">
|
||||||
{user?.first_name?.[0]}{user?.last_name?.[0]}
|
{user?.first_name?.[0]}{user?.last_name?.[0]}
|
||||||
</span>
|
</span>
|
||||||
@ -175,7 +181,7 @@ export function DashboardLayout() {
|
|||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-secondary-500">{user?.role}</p>
|
<p className="text-xs text-secondary-500">{user?.role}</p>
|
||||||
</div>
|
</div>
|
||||||
<ChevronDown className="w-4 h-4 text-secondary-400" />
|
<ChevronDown className="w-4 h-4 text-secondary-400" aria-hidden="true" />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{userMenuOpen && (
|
{userMenuOpen && (
|
||||||
@ -183,13 +189,20 @@ export function DashboardLayout() {
|
|||||||
<div
|
<div
|
||||||
className="fixed inset-0 z-40"
|
className="fixed inset-0 z-40"
|
||||||
onClick={() => setUserMenuOpen(false)}
|
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
|
<button
|
||||||
onClick={handleLogout}
|
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
|
Sign out
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -21,5 +21,6 @@
|
|||||||
"@/*": ["src/*"]
|
"@/*": ["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