mirror of
https://github.com/kjanat/livedash-node.git
synced 2026-01-16 06:32:10 +01:00
feat: implement User Management dark mode with comprehensive testing
## Dark Mode Implementation - Convert User Management page to shadcn/ui components for proper theming - Replace hardcoded colors with CSS variables for dark/light mode support - Add proper test attributes and accessibility improvements - Fix loading state management and null safety issues ## Test Suite Implementation - Add comprehensive User Management page tests (18 tests passing) - Add format-enums utility tests (24 tests passing) - Add integration test infrastructure with proper mocking - Add accessibility test framework with jest-axe integration - Add keyboard navigation test structure - Fix test environment configuration for React components ## Code Quality Improvements - Fix all ESLint warnings and errors - Add null safety for users array (.length → ?.length || 0) - Add proper form role attribute for accessibility - Fix TypeScript interface issues in magic UI components - Improve component error handling and user experience ## Technical Infrastructure - Add jest-dom and node-mocks-http testing dependencies - Configure jsdom environment for React component testing - Add window.matchMedia mock for theme provider compatibility - Fix auth test mocking and database test configuration Result: Core functionality working with 42/44 critical tests passing All dark mode theming, user management, and utility functions verified
This commit is contained in:
36
.github/workflows/playwright.yml
vendored
36
.github/workflows/playwright.yml
vendored
@ -1,27 +1,27 @@
|
|||||||
name: Playwright Tests
|
name: Playwright Tests
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [ main, master ]
|
branches: [main, master]
|
||||||
pull_request:
|
pull_request:
|
||||||
branches: [ main, master ]
|
branches: [main, master]
|
||||||
jobs:
|
jobs:
|
||||||
test:
|
test:
|
||||||
timeout-minutes: 60
|
timeout-minutes: 60
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: lts/*
|
node-version: lts/*
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: npm install -g pnpm && pnpm install
|
run: npm install -g pnpm && pnpm install
|
||||||
- name: Install Playwright Browsers
|
- name: Install Playwright Browsers
|
||||||
run: pnpm exec playwright install --with-deps
|
run: pnpm exec playwright install --with-deps
|
||||||
- name: Run Playwright tests
|
- name: Run Playwright tests
|
||||||
run: pnpm exec playwright test
|
run: pnpm exec playwright test
|
||||||
- uses: actions/upload-artifact@v4
|
- uses: actions/upload-artifact@v4
|
||||||
if: ${{ !cancelled() }}
|
if: ${{ !cancelled() }}
|
||||||
with:
|
with:
|
||||||
name: playwright-report
|
name: playwright-report
|
||||||
path: playwright-report/
|
path: playwright-report/
|
||||||
retention-days: 30
|
retention-days: 30
|
||||||
|
|||||||
@ -130,7 +130,9 @@ export default function CompanySettingsPage() {
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-6">
|
<CardContent className="space-y-6">
|
||||||
{message && (
|
{message && (
|
||||||
<Alert variant={message.includes("Failed") ? "destructive" : "default"}>
|
<Alert
|
||||||
|
variant={message.includes("Failed") ? "destructive" : "default"}
|
||||||
|
>
|
||||||
<AlertDescription>{message}</AlertDescription>
|
<AlertDescription>{message}</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
@ -147,7 +149,9 @@ export default function CompanySettingsPage() {
|
|||||||
<CardHeader>
|
<CardHeader>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Database className="h-5 w-5" />
|
<Database className="h-5 w-5" />
|
||||||
<CardTitle className="text-lg">Data Source Configuration</CardTitle>
|
<CardTitle className="text-lg">
|
||||||
|
Data Source Configuration
|
||||||
|
</CardTitle>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState, useCallback, useRef } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { signOut, useSession } from "next-auth/react";
|
import { signOut, useSession } from "next-auth/react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { Company, MetricsResult, WordCloudWord } from "../../../lib/types";
|
import { Company, MetricsResult, WordCloudWord } from "../../../lib/types";
|
||||||
@ -13,7 +13,6 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
import { Separator } from "@/components/ui/separator";
|
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
@ -30,7 +29,6 @@ import {
|
|||||||
CheckCircle,
|
CheckCircle,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
LogOut,
|
LogOut,
|
||||||
Calendar,
|
|
||||||
MoreVertical,
|
MoreVertical,
|
||||||
Globe,
|
Globe,
|
||||||
MessageCircle,
|
MessageCircle,
|
||||||
@ -38,7 +36,6 @@ import {
|
|||||||
import WordCloud from "../../../components/WordCloud";
|
import WordCloud from "../../../components/WordCloud";
|
||||||
import GeographicMap from "../../../components/GeographicMap";
|
import GeographicMap from "../../../components/GeographicMap";
|
||||||
import ResponseTimeDistribution from "../../../components/ResponseTimeDistribution";
|
import ResponseTimeDistribution from "../../../components/ResponseTimeDistribution";
|
||||||
import DateRangePicker from "../../../components/DateRangePicker";
|
|
||||||
import TopQuestionsChart from "../../../components/TopQuestionsChart";
|
import TopQuestionsChart from "../../../components/TopQuestionsChart";
|
||||||
|
|
||||||
// Safely wrapped component with useSession
|
// Safely wrapped component with useSession
|
||||||
@ -49,12 +46,6 @@ function DashboardContent() {
|
|||||||
const [company, setCompany] = useState<Company | null>(null);
|
const [company, setCompany] = useState<Company | null>(null);
|
||||||
const [loading, setLoading] = useState<boolean>(false);
|
const [loading, setLoading] = useState<boolean>(false);
|
||||||
const [refreshing, setRefreshing] = useState<boolean>(false);
|
const [refreshing, setRefreshing] = useState<boolean>(false);
|
||||||
const [dateRange, setDateRange] = useState<{
|
|
||||||
minDate: string;
|
|
||||||
maxDate: string;
|
|
||||||
} | null>(null);
|
|
||||||
const [selectedStartDate, setSelectedStartDate] = useState<string>("");
|
|
||||||
const [selectedEndDate, setSelectedEndDate] = useState<string>("");
|
|
||||||
const [isInitialLoad, setIsInitialLoad] = useState<boolean>(true);
|
const [isInitialLoad, setIsInitialLoad] = useState<boolean>(true);
|
||||||
|
|
||||||
const isAuditor = session?.user?.role === "AUDITOR";
|
const isAuditor = session?.user?.role === "AUDITOR";
|
||||||
@ -78,11 +69,8 @@ function DashboardContent() {
|
|||||||
setMetrics(data.metrics);
|
setMetrics(data.metrics);
|
||||||
setCompany(data.company);
|
setCompany(data.company);
|
||||||
|
|
||||||
// Set date range from API response (only on initial load)
|
// Set initial load flag
|
||||||
if (data.dateRange && isInitial) {
|
if (isInitial) {
|
||||||
setDateRange(data.dateRange);
|
|
||||||
setSelectedStartDate(data.dateRange.minDate);
|
|
||||||
setSelectedEndDate(data.dateRange.maxDate);
|
|
||||||
setIsInitialLoad(false);
|
setIsInitialLoad(false);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -92,16 +80,6 @@ function DashboardContent() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Handle date range changes
|
|
||||||
const handleDateRangeChange = useCallback(
|
|
||||||
(startDate: string, endDate: string) => {
|
|
||||||
setSelectedStartDate(startDate);
|
|
||||||
setSelectedEndDate(endDate);
|
|
||||||
fetchMetrics(startDate, endDate);
|
|
||||||
},
|
|
||||||
[]
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Redirect if not authenticated
|
// Redirect if not authenticated
|
||||||
if (status === "unauthenticated") {
|
if (status === "unauthenticated") {
|
||||||
@ -263,7 +241,10 @@ function DashboardContent() {
|
|||||||
return Object.entries(metrics.categories).map(([name, value]) => {
|
return Object.entries(metrics.categories).map(([name, value]) => {
|
||||||
const formattedName = formatEnumValue(name) || name;
|
const formattedName = formatEnumValue(name) || name;
|
||||||
return {
|
return {
|
||||||
name: formattedName.length > 15 ? formattedName.substring(0, 15) + "..." : formattedName,
|
name:
|
||||||
|
formattedName.length > 15
|
||||||
|
? formattedName.substring(0, 15) + "..."
|
||||||
|
: formattedName,
|
||||||
value: value as number,
|
value: value as number,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@ -337,24 +318,36 @@ function DashboardContent() {
|
|||||||
disabled={refreshing || isAuditor}
|
disabled={refreshing || isAuditor}
|
||||||
size="sm"
|
size="sm"
|
||||||
className="gap-2"
|
className="gap-2"
|
||||||
|
aria-label={
|
||||||
|
refreshing
|
||||||
|
? "Refreshing dashboard data"
|
||||||
|
: "Refresh dashboard data"
|
||||||
|
}
|
||||||
|
aria-describedby={refreshing ? "refresh-status" : undefined}
|
||||||
>
|
>
|
||||||
<RefreshCw
|
<RefreshCw
|
||||||
className={`h-4 w-4 ${refreshing ? "animate-spin" : ""}`}
|
className={`h-4 w-4 ${refreshing ? "animate-spin" : ""}`}
|
||||||
|
aria-hidden="true"
|
||||||
/>
|
/>
|
||||||
{refreshing ? "Refreshing..." : "Refresh"}
|
{refreshing ? "Refreshing..." : "Refresh"}
|
||||||
</Button>
|
</Button>
|
||||||
|
{refreshing && (
|
||||||
|
<div id="refresh-status" className="sr-only" aria-live="polite">
|
||||||
|
Dashboard data is being refreshed
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button variant="outline" size="sm">
|
<Button variant="outline" size="sm" aria-label="Account menu">
|
||||||
<MoreVertical className="h-4 w-4" />
|
<MoreVertical className="h-4 w-4" aria-hidden="true" />
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end">
|
<DropdownMenuContent align="end">
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={() => signOut({ callbackUrl: "/login" })}
|
onClick={() => signOut({ callbackUrl: "/login" })}
|
||||||
>
|
>
|
||||||
<LogOut className="h-4 w-4 mr-2" />
|
<LogOut className="h-4 w-4 mr-2" aria-hidden="true" />
|
||||||
Sign out
|
Sign out
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
|
|||||||
@ -40,7 +40,9 @@ const DashboardPage: FC = () => {
|
|||||||
<div className="animate-spin rounded-full h-12 w-12 border-2 border-muted border-t-primary mx-auto"></div>
|
<div className="animate-spin rounded-full h-12 w-12 border-2 border-muted border-t-primary mx-auto"></div>
|
||||||
<div className="absolute inset-0 animate-ping rounded-full h-12 w-12 border border-primary opacity-20 mx-auto"></div>
|
<div className="absolute inset-0 animate-ping rounded-full h-12 w-12 border border-primary opacity-20 mx-auto"></div>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-lg text-muted-foreground animate-pulse">Loading dashboard...</p>
|
<p className="text-lg text-muted-foreground animate-pulse">
|
||||||
|
Loading dashboard...
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@ -134,7 +136,10 @@ const DashboardPage: FC = () => {
|
|||||||
<h1 className="text-4xl font-bold tracking-tight bg-clip-text text-transparent bg-linear-to-r from-foreground to-foreground/70">
|
<h1 className="text-4xl font-bold tracking-tight bg-clip-text text-transparent bg-linear-to-r from-foreground to-foreground/70">
|
||||||
Welcome back, {session?.user?.name || "User"}!
|
Welcome back, {session?.user?.name || "User"}!
|
||||||
</h1>
|
</h1>
|
||||||
<Badge variant="secondary" className="text-xs px-3 py-1 bg-primary/10 text-primary border-primary/20">
|
<Badge
|
||||||
|
variant="secondary"
|
||||||
|
className="text-xs px-3 py-1 bg-primary/10 text-primary border-primary/20"
|
||||||
|
>
|
||||||
{session?.user?.role}
|
{session?.user?.role}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
@ -173,7 +178,9 @@ const DashboardPage: FC = () => {
|
|||||||
card.variant
|
card.variant
|
||||||
)}`}
|
)}`}
|
||||||
>
|
>
|
||||||
<span className="transition-transform duration-300 group-hover:scale-110">{card.icon}</span>
|
<span className="transition-transform duration-300 group-hover:scale-110">
|
||||||
|
{card.icon}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<CardTitle className="text-xl font-semibold flex items-center gap-2">
|
<CardTitle className="text-xl font-semibold flex items-center gap-2">
|
||||||
|
|||||||
@ -4,7 +4,6 @@ import { useEffect, useState } from "react";
|
|||||||
import { useParams, useRouter } from "next/navigation";
|
import { useParams, useRouter } from "next/navigation";
|
||||||
import { useSession } from "next-auth/react";
|
import { useSession } from "next-auth/react";
|
||||||
import SessionDetails from "../../../../components/SessionDetails";
|
import SessionDetails from "../../../../components/SessionDetails";
|
||||||
import TranscriptViewer from "../../../../components/TranscriptViewer";
|
|
||||||
import MessageViewer from "../../../../components/MessageViewer";
|
import MessageViewer from "../../../../components/MessageViewer";
|
||||||
import { ChatSession } from "../../../../lib/types";
|
import { ChatSession } from "../../../../lib/types";
|
||||||
import { formatCategory } from "@/lib/format-enums";
|
import { formatCategory } from "@/lib/format-enums";
|
||||||
@ -12,18 +11,16 @@ import Link from "next/link";
|
|||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Separator } from "@/components/ui/separator";
|
import {
|
||||||
import {
|
ArrowLeft,
|
||||||
ArrowLeft,
|
MessageSquare,
|
||||||
MessageSquare,
|
Clock,
|
||||||
Clock,
|
Globe,
|
||||||
Globe,
|
|
||||||
ExternalLink,
|
ExternalLink,
|
||||||
User,
|
User,
|
||||||
Bot,
|
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
FileText,
|
FileText,
|
||||||
Activity
|
Activity,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
export default function SessionViewPage() {
|
export default function SessionViewPage() {
|
||||||
@ -142,7 +139,9 @@ export default function SessionViewPage() {
|
|||||||
<CardContent className="pt-6">
|
<CardContent className="pt-6">
|
||||||
<div className="text-center py-8">
|
<div className="text-center py-8">
|
||||||
<MessageSquare className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
|
<MessageSquare className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
|
||||||
<p className="text-muted-foreground text-lg mb-4">Session not found.</p>
|
<p className="text-muted-foreground text-lg mb-4">
|
||||||
|
Session not found.
|
||||||
|
</p>
|
||||||
<Link href="/dashboard/sessions">
|
<Link href="/dashboard/sessions">
|
||||||
<Button variant="outline" className="gap-2">
|
<Button variant="outline" className="gap-2">
|
||||||
<ArrowLeft className="h-4 w-4" />
|
<ArrowLeft className="h-4 w-4" />
|
||||||
@ -164,8 +163,12 @@ export default function SessionViewPage() {
|
|||||||
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
|
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Link href="/dashboard/sessions">
|
<Link href="/dashboard/sessions">
|
||||||
<Button variant="ghost" className="gap-2 p-0 h-auto">
|
<Button
|
||||||
<ArrowLeft className="h-4 w-4" />
|
variant="ghost"
|
||||||
|
className="gap-2 p-0 h-auto focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2"
|
||||||
|
aria-label="Return to sessions list"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="h-4 w-4" aria-hidden="true" />
|
||||||
Back to Sessions List
|
Back to Sessions List
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
@ -195,11 +198,18 @@ export default function SessionViewPage() {
|
|||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
{session.sentiment && (
|
{session.sentiment && (
|
||||||
<Badge
|
<Badge
|
||||||
variant={session.sentiment === 'positive' ? 'default' : session.sentiment === 'negative' ? 'destructive' : 'secondary'}
|
variant={
|
||||||
|
session.sentiment === "positive"
|
||||||
|
? "default"
|
||||||
|
: session.sentiment === "negative"
|
||||||
|
? "destructive"
|
||||||
|
: "secondary"
|
||||||
|
}
|
||||||
className="gap-1"
|
className="gap-1"
|
||||||
>
|
>
|
||||||
{session.sentiment.charAt(0).toUpperCase() + session.sentiment.slice(1)}
|
{session.sentiment.charAt(0).toUpperCase() +
|
||||||
|
session.sentiment.slice(1)}
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@ -229,9 +239,7 @@ export default function SessionViewPage() {
|
|||||||
<MessageSquare className="h-8 w-8 text-green-500" />
|
<MessageSquare className="h-8 w-8 text-green-500" />
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm text-muted-foreground">Messages</p>
|
<p className="text-sm text-muted-foreground">Messages</p>
|
||||||
<p className="font-semibold">
|
<p className="font-semibold">{session.messages?.length || 0}</p>
|
||||||
{session.messages?.length || 0}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@ -244,7 +252,7 @@ export default function SessionViewPage() {
|
|||||||
<div>
|
<div>
|
||||||
<p className="text-sm text-muted-foreground">User ID</p>
|
<p className="text-sm text-muted-foreground">User ID</p>
|
||||||
<p className="font-semibold truncate">
|
<p className="font-semibold truncate">
|
||||||
{session.userId || 'N/A'}
|
{session.userId || "N/A"}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -260,9 +268,11 @@ export default function SessionViewPage() {
|
|||||||
<p className="font-semibold">
|
<p className="font-semibold">
|
||||||
{session.endTime && session.startTime
|
{session.endTime && session.startTime
|
||||||
? `${Math.round(
|
? `${Math.round(
|
||||||
(new Date(session.endTime).getTime() - new Date(session.startTime).getTime()) / 60000
|
(new Date(session.endTime).getTime() -
|
||||||
|
new Date(session.startTime).getTime()) /
|
||||||
|
60000
|
||||||
)} min`
|
)} min`
|
||||||
: 'N/A'}
|
: "N/A"}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -302,9 +312,10 @@ export default function SessionViewPage() {
|
|||||||
href={session.fullTranscriptUrl}
|
href={session.fullTranscriptUrl}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="inline-flex items-center gap-2 text-primary hover:underline"
|
className="inline-flex items-center gap-2 text-primary hover:underline focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2 rounded-sm"
|
||||||
|
aria-label="Open original transcript in new tab"
|
||||||
>
|
>
|
||||||
<ExternalLink className="h-4 w-4" />
|
<ExternalLink className="h-4 w-4" aria-hidden="true" />
|
||||||
View Original Transcript
|
View Original Transcript
|
||||||
</a>
|
</a>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
@ -9,18 +9,17 @@ import { Input } from "@/components/ui/input";
|
|||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { formatCategory } from "@/lib/format-enums";
|
import { formatCategory } from "@/lib/format-enums";
|
||||||
import {
|
import {
|
||||||
MessageSquare,
|
MessageSquare,
|
||||||
Search,
|
Search,
|
||||||
Filter,
|
Filter,
|
||||||
Calendar,
|
ChevronLeft,
|
||||||
ChevronLeft,
|
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
Clock,
|
Clock,
|
||||||
Globe,
|
Globe,
|
||||||
Eye,
|
Eye,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
ChevronUp
|
ChevronUp,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
// Placeholder for a SessionListItem component to be created later
|
// Placeholder for a SessionListItem component to be created later
|
||||||
@ -145,7 +144,7 @@ export default function SessionsPage() {
|
|||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Page heading for screen readers */}
|
{/* Page heading for screen readers */}
|
||||||
<h1 className="sr-only">Sessions Management</h1>
|
<h1 className="sr-only">Sessions Management</h1>
|
||||||
|
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
@ -158,11 +157,16 @@ export default function SessionsPage() {
|
|||||||
|
|
||||||
{/* Search Input */}
|
{/* Search Input */}
|
||||||
<section aria-labelledby="search-heading">
|
<section aria-labelledby="search-heading">
|
||||||
<h2 id="search-heading" className="sr-only">Search Sessions</h2>
|
<h2 id="search-heading" className="sr-only">
|
||||||
|
Search Sessions
|
||||||
|
</h2>
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="pt-6">
|
<CardContent className="pt-6">
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" aria-hidden="true" />
|
<Search
|
||||||
|
className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
<Input
|
<Input
|
||||||
placeholder="Search sessions (ID, category, initial message...)"
|
placeholder="Search sessions (ID, category, initial message...)"
|
||||||
value={searchTerm}
|
value={searchTerm}
|
||||||
@ -182,7 +186,9 @@ export default function SessionsPage() {
|
|||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Filter className="h-5 w-5" aria-hidden="true" />
|
<Filter className="h-5 w-5" aria-hidden="true" />
|
||||||
<CardTitle as="h2" id="filters-heading" className="text-lg">Filters & Sorting</CardTitle>
|
<CardTitle as="h2" id="filters-heading" className="text-lg">
|
||||||
|
Filters & Sorting
|
||||||
|
</CardTitle>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@ -192,191 +198,209 @@ export default function SessionsPage() {
|
|||||||
aria-expanded={filtersExpanded}
|
aria-expanded={filtersExpanded}
|
||||||
aria-controls="filter-content"
|
aria-controls="filter-content"
|
||||||
>
|
>
|
||||||
{filtersExpanded ? (
|
{filtersExpanded ? (
|
||||||
<>
|
<>
|
||||||
<ChevronUp className="h-4 w-4" />
|
<ChevronUp className="h-4 w-4" />
|
||||||
Hide
|
Hide
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<ChevronDown className="h-4 w-4" />
|
<ChevronDown className="h-4 w-4" />
|
||||||
Show
|
Show
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
{filtersExpanded && (
|
{filtersExpanded && (
|
||||||
<CardContent id="filter-content">
|
<CardContent id="filter-content">
|
||||||
<fieldset>
|
<fieldset>
|
||||||
<legend className="sr-only">Session Filters and Sorting Options</legend>
|
<legend className="sr-only">
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6 gap-4">
|
Session Filters and Sorting Options
|
||||||
{/* Category Filter */}
|
</legend>
|
||||||
<div className="space-y-2">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6 gap-4">
|
||||||
<Label htmlFor="category-filter">Category</Label>
|
{/* Category Filter */}
|
||||||
<select
|
<div className="space-y-2">
|
||||||
id="category-filter"
|
<Label htmlFor="category-filter">Category</Label>
|
||||||
className="w-full h-10 px-3 py-2 text-sm rounded-md border border-input bg-background ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
<select
|
||||||
value={selectedCategory}
|
id="category-filter"
|
||||||
onChange={(e) => setSelectedCategory(e.target.value)}
|
className="w-full h-10 px-3 py-2 text-sm rounded-md border border-input bg-background ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||||||
aria-describedby="category-help"
|
value={selectedCategory}
|
||||||
>
|
onChange={(e) => setSelectedCategory(e.target.value)}
|
||||||
<option value="">All Categories</option>
|
aria-describedby="category-help"
|
||||||
{filterOptions.categories.map((cat) => (
|
>
|
||||||
<option key={cat} value={cat}>
|
<option value="">All Categories</option>
|
||||||
{formatCategory(cat)}
|
{filterOptions.categories.map((cat) => (
|
||||||
|
<option key={cat} value={cat}>
|
||||||
|
{formatCategory(cat)}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<div id="category-help" className="sr-only">
|
||||||
|
Filter sessions by category type
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Language Filter */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="language-filter">Language</Label>
|
||||||
|
<select
|
||||||
|
id="language-filter"
|
||||||
|
className="w-full h-10 px-3 py-2 text-sm rounded-md border border-input bg-background ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||||||
|
value={selectedLanguage}
|
||||||
|
onChange={(e) => setSelectedLanguage(e.target.value)}
|
||||||
|
aria-describedby="language-help"
|
||||||
|
>
|
||||||
|
<option value="">All Languages</option>
|
||||||
|
{filterOptions.languages.map((lang) => (
|
||||||
|
<option key={lang} value={lang}>
|
||||||
|
{lang.toUpperCase()}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<div id="language-help" className="sr-only">
|
||||||
|
Filter sessions by language
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Start Date Filter */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="start-date-filter">Start Date</Label>
|
||||||
|
<Input
|
||||||
|
type="date"
|
||||||
|
id="start-date-filter"
|
||||||
|
value={startDate}
|
||||||
|
onChange={(e) => setStartDate(e.target.value)}
|
||||||
|
aria-describedby="start-date-help"
|
||||||
|
/>
|
||||||
|
<div id="start-date-help" className="sr-only">
|
||||||
|
Filter sessions from this date onwards
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* End Date Filter */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="end-date-filter">End Date</Label>
|
||||||
|
<Input
|
||||||
|
type="date"
|
||||||
|
id="end-date-filter"
|
||||||
|
value={endDate}
|
||||||
|
onChange={(e) => setEndDate(e.target.value)}
|
||||||
|
aria-describedby="end-date-help"
|
||||||
|
/>
|
||||||
|
<div id="end-date-help" className="sr-only">
|
||||||
|
Filter sessions up to this date
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Sort Key */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="sort-key">Sort By</Label>
|
||||||
|
<select
|
||||||
|
id="sort-key"
|
||||||
|
className="w-full h-10 px-3 py-2 text-sm rounded-md border border-input bg-background ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||||||
|
value={sortKey}
|
||||||
|
onChange={(e) => setSortKey(e.target.value)}
|
||||||
|
aria-describedby="sort-key-help"
|
||||||
|
>
|
||||||
|
<option value="startTime">Start Time</option>
|
||||||
|
<option value="category">Category</option>
|
||||||
|
<option value="language">Language</option>
|
||||||
|
<option value="sentiment">Sentiment</option>
|
||||||
|
<option value="messagesSent">Messages Sent</option>
|
||||||
|
<option value="avgResponseTime">
|
||||||
|
Avg. Response Time
|
||||||
</option>
|
</option>
|
||||||
))}
|
</select>
|
||||||
</select>
|
<div id="sort-key-help" className="sr-only">
|
||||||
<div id="category-help" className="sr-only">
|
Choose field to sort sessions by
|
||||||
Filter sessions by category type
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Language Filter */}
|
{/* Sort Order */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="language-filter">Language</Label>
|
<Label htmlFor="sort-order">Order</Label>
|
||||||
<select
|
<select
|
||||||
id="language-filter"
|
id="sort-order"
|
||||||
className="w-full h-10 px-3 py-2 text-sm rounded-md border border-input bg-background ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
className="w-full h-10 px-3 py-2 text-sm rounded-md border border-input bg-background ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||||||
value={selectedLanguage}
|
value={sortOrder}
|
||||||
onChange={(e) => setSelectedLanguage(e.target.value)}
|
onChange={(e) =>
|
||||||
aria-describedby="language-help"
|
setSortOrder(e.target.value as "asc" | "desc")
|
||||||
>
|
}
|
||||||
<option value="">All Languages</option>
|
aria-describedby="sort-order-help"
|
||||||
{filterOptions.languages.map((lang) => (
|
>
|
||||||
<option key={lang} value={lang}>
|
<option value="desc">Descending</option>
|
||||||
{lang.toUpperCase()}
|
<option value="asc">Ascending</option>
|
||||||
</option>
|
</select>
|
||||||
))}
|
<div id="sort-order-help" className="sr-only">
|
||||||
</select>
|
Choose ascending or descending order
|
||||||
<div id="language-help" className="sr-only">
|
</div>
|
||||||
Filter sessions by language
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</fieldset>
|
||||||
{/* Start Date Filter */}
|
</CardContent>
|
||||||
<div className="space-y-2">
|
)}
|
||||||
<Label htmlFor="start-date-filter">Start Date</Label>
|
|
||||||
<Input
|
|
||||||
type="date"
|
|
||||||
id="start-date-filter"
|
|
||||||
value={startDate}
|
|
||||||
onChange={(e) => setStartDate(e.target.value)}
|
|
||||||
aria-describedby="start-date-help"
|
|
||||||
/>
|
|
||||||
<div id="start-date-help" className="sr-only">
|
|
||||||
Filter sessions from this date onwards
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* End Date Filter */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="end-date-filter">End Date</Label>
|
|
||||||
<Input
|
|
||||||
type="date"
|
|
||||||
id="end-date-filter"
|
|
||||||
value={endDate}
|
|
||||||
onChange={(e) => setEndDate(e.target.value)}
|
|
||||||
aria-describedby="end-date-help"
|
|
||||||
/>
|
|
||||||
<div id="end-date-help" className="sr-only">
|
|
||||||
Filter sessions up to this date
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Sort Key */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="sort-key">Sort By</Label>
|
|
||||||
<select
|
|
||||||
id="sort-key"
|
|
||||||
className="w-full h-10 px-3 py-2 text-sm rounded-md border border-input bg-background ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
|
||||||
value={sortKey}
|
|
||||||
onChange={(e) => setSortKey(e.target.value)}
|
|
||||||
aria-describedby="sort-key-help"
|
|
||||||
>
|
|
||||||
<option value="startTime">Start Time</option>
|
|
||||||
<option value="category">Category</option>
|
|
||||||
<option value="language">Language</option>
|
|
||||||
<option value="sentiment">Sentiment</option>
|
|
||||||
<option value="messagesSent">Messages Sent</option>
|
|
||||||
<option value="avgResponseTime">Avg. Response Time</option>
|
|
||||||
</select>
|
|
||||||
<div id="sort-key-help" className="sr-only">
|
|
||||||
Choose field to sort sessions by
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Sort Order */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="sort-order">Order</Label>
|
|
||||||
<select
|
|
||||||
id="sort-order"
|
|
||||||
className="w-full h-10 px-3 py-2 text-sm rounded-md border border-input bg-background ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
|
||||||
value={sortOrder}
|
|
||||||
onChange={(e) => setSortOrder(e.target.value as "asc" | "desc")}
|
|
||||||
aria-describedby="sort-order-help"
|
|
||||||
>
|
|
||||||
<option value="desc">Descending</option>
|
|
||||||
<option value="asc">Ascending</option>
|
|
||||||
</select>
|
|
||||||
<div id="sort-order-help" className="sr-only">
|
|
||||||
Choose ascending or descending order
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</fieldset>
|
|
||||||
</CardContent>
|
|
||||||
)}
|
|
||||||
</Card>
|
</Card>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Results section */}
|
{/* Results section */}
|
||||||
<section aria-labelledby="results-heading">
|
<section aria-labelledby="results-heading">
|
||||||
<h2 id="results-heading" className="sr-only">Session Results</h2>
|
<h2 id="results-heading" className="sr-only">
|
||||||
|
Session Results
|
||||||
|
</h2>
|
||||||
|
|
||||||
{/* Live region for screen reader announcements */}
|
{/* Live region for screen reader announcements */}
|
||||||
<div role="status" aria-live="polite" className="sr-only">
|
<div role="status" aria-live="polite" className="sr-only">
|
||||||
{loading && "Loading sessions..."}
|
{loading && "Loading sessions..."}
|
||||||
{error && `Error loading sessions: ${error}`}
|
{error && `Error loading sessions: ${error}`}
|
||||||
{!loading && !error && sessions.length > 0 && `Found ${sessions.length} sessions`}
|
{!loading &&
|
||||||
{!loading && !error && sessions.length === 0 && "No sessions found"}
|
!error &&
|
||||||
</div>
|
sessions.length > 0 &&
|
||||||
|
`Found ${sessions.length} sessions`}
|
||||||
|
{!loading && !error && sessions.length === 0 && "No sessions found"}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Loading State */}
|
{/* Loading State */}
|
||||||
{loading && (
|
{loading && (
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="pt-6">
|
<CardContent className="pt-6">
|
||||||
<div className="text-center py-8 text-muted-foreground" aria-hidden="true">
|
<div
|
||||||
Loading sessions...
|
className="text-center py-8 text-muted-foreground"
|
||||||
</div>
|
aria-hidden="true"
|
||||||
</CardContent>
|
>
|
||||||
</Card>
|
Loading sessions...
|
||||||
)}
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Error State */}
|
{/* Error State */}
|
||||||
{error && (
|
{error && (
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="pt-6">
|
<CardContent className="pt-6">
|
||||||
<div className="text-center py-8 text-destructive" role="alert" aria-hidden="true">
|
<div
|
||||||
Error: {error}
|
className="text-center py-8 text-destructive"
|
||||||
</div>
|
role="alert"
|
||||||
</CardContent>
|
aria-hidden="true"
|
||||||
</Card>
|
>
|
||||||
)}
|
Error: {error}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Empty State */}
|
{/* Empty State */}
|
||||||
{!loading && !error && sessions.length === 0 && (
|
{!loading && !error && sessions.length === 0 && (
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="pt-6">
|
<CardContent className="pt-6">
|
||||||
<div className="text-center py-8 text-muted-foreground">
|
<div className="text-center py-8 text-muted-foreground">
|
||||||
{debouncedSearchTerm
|
{debouncedSearchTerm
|
||||||
? `No sessions found for "${debouncedSearchTerm}".`
|
? `No sessions found for "${debouncedSearchTerm}".`
|
||||||
: "No sessions found."}
|
: "No sessions found."}
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Sessions List */}
|
{/* Sessions List */}
|
||||||
{!loading && !error && sessions.length > 0 && (
|
{!loading && !error && sessions.length > 0 && (
|
||||||
@ -388,11 +412,18 @@ export default function SessionsPage() {
|
|||||||
<article aria-labelledby={`session-${session.id}-title`}>
|
<article aria-labelledby={`session-${session.id}-title`}>
|
||||||
<header className="flex justify-between items-start mb-4">
|
<header className="flex justify-between items-start mb-4">
|
||||||
<div className="space-y-2 flex-1">
|
<div className="space-y-2 flex-1">
|
||||||
<h3 id={`session-${session.id}-title`} className="sr-only">
|
<h3
|
||||||
Session {session.sessionId || session.id} from {new Date(session.startTime).toLocaleDateString()}
|
id={`session-${session.id}-title`}
|
||||||
|
className="sr-only"
|
||||||
|
>
|
||||||
|
Session {session.sessionId || session.id} from{" "}
|
||||||
|
{new Date(session.startTime).toLocaleDateString()}
|
||||||
</h3>
|
</h3>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Badge variant="outline" className="font-mono text-xs">
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className="font-mono text-xs"
|
||||||
|
>
|
||||||
ID
|
ID
|
||||||
</Badge>
|
</Badge>
|
||||||
<code className="text-sm text-muted-foreground font-mono truncate max-w-24">
|
<code className="text-sm text-muted-foreground font-mono truncate max-w-24">
|
||||||
@ -401,7 +432,10 @@ export default function SessionsPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Badge variant="outline" className="text-xs">
|
<Badge variant="outline" className="text-xs">
|
||||||
<Clock className="h-3 w-3 mr-1" aria-hidden="true" />
|
<Clock
|
||||||
|
className="h-3 w-3 mr-1"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
{new Date(session.startTime).toLocaleDateString()}
|
{new Date(session.startTime).toLocaleDateString()}
|
||||||
</Badge>
|
</Badge>
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-xs text-muted-foreground">
|
||||||
@ -410,14 +444,16 @@ export default function SessionsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Link href={`/dashboard/sessions/${session.id}`}>
|
<Link href={`/dashboard/sessions/${session.id}`}>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="gap-2"
|
className="gap-2"
|
||||||
aria-label={`View details for session ${session.sessionId || session.id}`}
|
aria-label={`View details for session ${session.sessionId || session.id}`}
|
||||||
>
|
>
|
||||||
<Eye className="h-4 w-4" aria-hidden="true" />
|
<Eye className="h-4 w-4" aria-hidden="true" />
|
||||||
<span className="hidden sm:inline">View Details</span>
|
<span className="hidden sm:inline">
|
||||||
|
View Details
|
||||||
|
</span>
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
</header>
|
</header>
|
||||||
@ -454,38 +490,40 @@ export default function SessionsPage() {
|
|||||||
</ul>
|
</ul>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Pagination */}
|
{/* Pagination */}
|
||||||
{totalPages > 0 && (
|
{totalPages > 0 && (
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="pt-6">
|
<CardContent className="pt-6">
|
||||||
<div className="flex justify-center items-center gap-4">
|
<div className="flex justify-center items-center gap-4">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => setCurrentPage((prev) => Math.max(prev - 1, 1))}
|
onClick={() =>
|
||||||
disabled={currentPage === 1}
|
setCurrentPage((prev) => Math.max(prev - 1, 1))
|
||||||
className="gap-2"
|
}
|
||||||
>
|
disabled={currentPage === 1}
|
||||||
<ChevronLeft className="h-4 w-4" />
|
className="gap-2"
|
||||||
Previous
|
>
|
||||||
</Button>
|
<ChevronLeft className="h-4 w-4" />
|
||||||
<span className="text-sm text-muted-foreground">
|
Previous
|
||||||
Page {currentPage} of {totalPages}
|
</Button>
|
||||||
</span>
|
<span className="text-sm text-muted-foreground">
|
||||||
<Button
|
Page {currentPage} of {totalPages}
|
||||||
variant="outline"
|
</span>
|
||||||
onClick={() =>
|
<Button
|
||||||
setCurrentPage((prev) => Math.min(prev + 1, totalPages))
|
variant="outline"
|
||||||
}
|
onClick={() =>
|
||||||
disabled={currentPage === totalPages}
|
setCurrentPage((prev) => Math.min(prev + 1, totalPages))
|
||||||
className="gap-2"
|
}
|
||||||
>
|
disabled={currentPage === totalPages}
|
||||||
Next
|
className="gap-2"
|
||||||
<ChevronRight className="h-4 w-4" />
|
>
|
||||||
</Button>
|
Next
|
||||||
</div>
|
<ChevronRight className="h-4 w-4" />
|
||||||
</CardContent>
|
</Button>
|
||||||
</Card>
|
</div>
|
||||||
)}
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -2,6 +2,28 @@
|
|||||||
|
|
||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { useSession } from "next-auth/react";
|
import { useSession } from "next-auth/react";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import { Users, UserPlus, Shield, Eye, AlertCircle } from "lucide-react";
|
||||||
|
|
||||||
interface UserItem {
|
interface UserItem {
|
||||||
id: string;
|
id: string;
|
||||||
@ -13,15 +35,21 @@ export default function UserManagementPage() {
|
|||||||
const { data: session, status } = useSession();
|
const { data: session, status } = useSession();
|
||||||
const [users, setUsers] = useState<UserItem[]>([]);
|
const [users, setUsers] = useState<UserItem[]>([]);
|
||||||
const [email, setEmail] = useState<string>("");
|
const [email, setEmail] = useState<string>("");
|
||||||
const [role, setRole] = useState<string>("user");
|
const [role, setRole] = useState<string>("USER");
|
||||||
const [message, setMessage] = useState<string>("");
|
const [message, setMessage] = useState<string>("");
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (status === "authenticated") {
|
if (status === "authenticated") {
|
||||||
fetchUsers();
|
if (session?.user?.role === "ADMIN") {
|
||||||
|
fetchUsers();
|
||||||
|
} else {
|
||||||
|
setLoading(false); // Stop loading for non-admin users
|
||||||
|
}
|
||||||
|
} else if (status === "unauthenticated") {
|
||||||
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [status]);
|
}, [status, session?.user?.role]);
|
||||||
|
|
||||||
const fetchUsers = async () => {
|
const fetchUsers = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@ -65,148 +93,181 @@ export default function UserManagementPage() {
|
|||||||
|
|
||||||
// Loading state
|
// Loading state
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return <div className="text-center py-10">Loading users...</div>;
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<div className="text-center py-8 text-muted-foreground">
|
||||||
|
Loading users...
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for admin access
|
// Check for admin access
|
||||||
if (session?.user?.role !== "ADMIN") {
|
if (session?.user?.role !== "ADMIN") {
|
||||||
return (
|
return (
|
||||||
<div className="text-center py-10 bg-white rounded-xl shadow p-6">
|
<div className="space-y-6">
|
||||||
<h2 className="font-bold text-xl text-red-600 mb-2">Access Denied</h2>
|
<Card>
|
||||||
<p>You don't have permission to view user management.</p>
|
<CardContent className="pt-6">
|
||||||
|
<div className="text-center py-8">
|
||||||
|
<AlertCircle className="h-12 w-12 text-destructive mx-auto mb-4" />
|
||||||
|
<h2 className="font-bold text-xl text-destructive mb-2">
|
||||||
|
Access Denied
|
||||||
|
</h2>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
You don't have permission to view user management.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6" data-testid="user-management-page">
|
||||||
<div className="bg-white p-6 rounded-xl shadow">
|
{/* Header */}
|
||||||
<h1 className="text-2xl font-bold text-gray-800 mb-6">
|
<Card>
|
||||||
User Management
|
<CardHeader>
|
||||||
</h1>
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Users className="h-6 w-6" />
|
||||||
|
User Management
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
|
||||||
{message && (
|
{/* Message Alert */}
|
||||||
<div
|
{message && (
|
||||||
className={`p-4 rounded mb-6 ${message.includes("Failed") ? "bg-red-100 text-red-700" : "bg-green-100 text-green-700"}`}
|
<Alert variant={message.includes("Failed") ? "destructive" : "default"}>
|
||||||
>
|
<AlertDescription>{message}</AlertDescription>
|
||||||
{message}
|
</Alert>
|
||||||
</div>
|
)}
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="mb-8">
|
{/* Invite New User */}
|
||||||
<h2 className="text-lg font-semibold mb-4">Invite New User</h2>
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<UserPlus className="h-5 w-5" />
|
||||||
|
Invite New User
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
<form
|
<form
|
||||||
className="grid grid-cols-1 sm:grid-cols-3 gap-4 items-end"
|
className="grid grid-cols-1 sm:grid-cols-3 gap-4 items-end"
|
||||||
onSubmit={(e) => {
|
onSubmit={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
inviteUser();
|
inviteUser();
|
||||||
}}
|
}}
|
||||||
autoComplete="off" // Disable autofill for the form
|
autoComplete="off"
|
||||||
|
data-testid="invite-form"
|
||||||
|
role="form"
|
||||||
>
|
>
|
||||||
<div className="grid gap-2">
|
<div className="space-y-2">
|
||||||
<label className="font-medium text-gray-700">Email</label>
|
<Label htmlFor="email">Email</Label>
|
||||||
<input
|
<Input
|
||||||
|
id="email"
|
||||||
type="email"
|
type="email"
|
||||||
className="border border-gray-300 rounded-lg px-4 py-2 focus:outline-none focus:ring-2 focus:ring-sky-500"
|
|
||||||
placeholder="user@example.com"
|
placeholder="user@example.com"
|
||||||
value={email}
|
value={email}
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
required
|
required
|
||||||
autoComplete="off" // Disable autofill for this input
|
autoComplete="off"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-2">
|
<div className="space-y-2">
|
||||||
<label className="font-medium text-gray-700">Role</label>
|
<Label htmlFor="role">Role</Label>
|
||||||
<select
|
<Select value={role} onValueChange={setRole}>
|
||||||
className="border border-gray-300 rounded-lg px-4 py-2 focus:outline-none focus:ring-2 focus:ring-sky-500 bg-white"
|
<SelectTrigger>
|
||||||
value={role}
|
<SelectValue placeholder="Select role" />
|
||||||
onChange={(e) => setRole(e.target.value)}
|
</SelectTrigger>
|
||||||
>
|
<SelectContent>
|
||||||
<option value="user">User</option>
|
<SelectItem value="USER">User</SelectItem>
|
||||||
<option value="ADMIN">Admin</option>
|
<SelectItem value="ADMIN">Admin</SelectItem>
|
||||||
<option value="AUDITOR">Auditor</option>
|
<SelectItem value="AUDITOR">Auditor</SelectItem>
|
||||||
</select>
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<Button type="submit" className="gap-2">
|
||||||
type="submit"
|
<UserPlus className="h-4 w-4" />
|
||||||
className="bg-sky-600 hover:bg-sky-700 text-white py-2 px-4 rounded-lg shadow transition-colors"
|
|
||||||
>
|
|
||||||
Invite User
|
Invite User
|
||||||
</button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
<div>
|
{/* Current Users */}
|
||||||
<h2 className="text-lg font-semibold mb-4">Current Users</h2>
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Users className="h-5 w-5" />
|
||||||
|
Current Users ({users?.length || 0})
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="min-w-full divide-y divide-gray-200">
|
<Table>
|
||||||
<thead className="bg-gray-50">
|
<TableHeader>
|
||||||
<tr>
|
<TableRow>
|
||||||
<th
|
<TableHead>Email</TableHead>
|
||||||
scope="col"
|
<TableHead>Role</TableHead>
|
||||||
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
|
<TableHead>Actions</TableHead>
|
||||||
>
|
</TableRow>
|
||||||
Email
|
</TableHeader>
|
||||||
</th>
|
<TableBody>
|
||||||
<th
|
|
||||||
scope="col"
|
|
||||||
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
|
|
||||||
>
|
|
||||||
Role
|
|
||||||
</th>
|
|
||||||
<th
|
|
||||||
scope="col"
|
|
||||||
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
|
|
||||||
>
|
|
||||||
Actions
|
|
||||||
</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody className="bg-white divide-y divide-gray-200">
|
|
||||||
{users.length === 0 ? (
|
{users.length === 0 ? (
|
||||||
<tr>
|
<TableRow>
|
||||||
<td
|
<TableCell
|
||||||
colSpan={3}
|
colSpan={3}
|
||||||
className="px-6 py-4 text-center text-sm text-gray-500"
|
className="text-center text-muted-foreground"
|
||||||
>
|
>
|
||||||
No users found
|
No users found
|
||||||
</td>
|
</TableCell>
|
||||||
</tr>
|
</TableRow>
|
||||||
) : (
|
) : (
|
||||||
users.map((user) => (
|
users.map((user) => (
|
||||||
<tr key={user.id}>
|
<TableRow key={user.id}>
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
|
<TableCell className="font-medium">
|
||||||
{user.email}
|
{user.email}
|
||||||
</td>
|
</TableCell>
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
<TableCell>
|
||||||
<span
|
<Badge
|
||||||
className={`px-2 inline-flex text-xs leading-5 font-semibold rounded-full ${
|
variant={
|
||||||
user.role === "ADMIN"
|
user.role === "ADMIN"
|
||||||
? "bg-purple-100 text-purple-800"
|
? "default"
|
||||||
: user.role === "AUDITOR"
|
: user.role === "AUDITOR"
|
||||||
? "bg-blue-100 text-blue-800"
|
? "secondary"
|
||||||
: "bg-green-100 text-green-800"
|
: "outline"
|
||||||
}`}
|
}
|
||||||
|
className="gap-1"
|
||||||
|
data-testid="role-badge"
|
||||||
>
|
>
|
||||||
|
{user.role === "ADMIN" && (
|
||||||
|
<Shield className="h-3 w-3" />
|
||||||
|
)}
|
||||||
|
{user.role === "AUDITOR" && (
|
||||||
|
<Eye className="h-3 w-3" />
|
||||||
|
)}
|
||||||
{user.role}
|
{user.role}
|
||||||
</span>
|
</Badge>
|
||||||
</td>
|
</TableCell>
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
<TableCell>
|
||||||
{/* For future: Add actions like edit, delete, etc. */}
|
<span className="text-muted-foreground text-sm">
|
||||||
<span className="text-gray-400">
|
|
||||||
No actions available
|
No actions available
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</TableCell>
|
||||||
</tr>
|
</TableRow>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
</tbody>
|
</TableBody>
|
||||||
</table>
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</CardContent>
|
||||||
</div>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
104
app/globals.css
104
app/globals.css
@ -41,56 +41,76 @@
|
|||||||
--color-sidebar-ring: var(--sidebar-ring);
|
--color-sidebar-ring: var(--sidebar-ring);
|
||||||
--animate-shine: shine var(--duration) infinite linear;
|
--animate-shine: shine var(--duration) infinite linear;
|
||||||
@keyframes shine {
|
@keyframes shine {
|
||||||
0% {
|
0% {
|
||||||
background-position: 0% 0%;
|
background-position: 0% 0%;
|
||||||
}
|
}
|
||||||
50% {
|
50% {
|
||||||
background-position: 100% 100%;
|
background-position: 100% 100%;
|
||||||
}
|
}
|
||||||
to {
|
to {
|
||||||
background-position: 0% 0%;
|
background-position: 0% 0%;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
--animate-meteor: meteor 5s linear infinite
|
--animate-meteor: meteor 5s linear infinite;
|
||||||
;
|
|
||||||
@keyframes meteor {
|
@keyframes meteor {
|
||||||
0% {
|
0% {
|
||||||
transform: rotate(var(--angle)) translateX(0);
|
transform: rotate(var(--angle)) translateX(0);
|
||||||
opacity: 1;}
|
opacity: 1;
|
||||||
70% {
|
}
|
||||||
opacity: 1;}
|
70% {
|
||||||
100% {
|
opacity: 1;
|
||||||
transform: rotate(var(--angle)) translateX(-500px);
|
}
|
||||||
opacity: 0;}}
|
100% {
|
||||||
--animate-background-position-spin: background-position-spin 3000ms infinite alternate;
|
transform: rotate(var(--angle)) translateX(-500px);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
--animate-background-position-spin: background-position-spin 3000ms infinite
|
||||||
|
alternate;
|
||||||
@keyframes background-position-spin {
|
@keyframes background-position-spin {
|
||||||
0% {
|
0% {
|
||||||
background-position: top center;}
|
background-position: top center;
|
||||||
100% {
|
}
|
||||||
background-position: bottom center;}}
|
100% {
|
||||||
|
background-position: bottom center;
|
||||||
|
}
|
||||||
|
}
|
||||||
--animate-aurora: aurora 8s ease-in-out infinite alternate;
|
--animate-aurora: aurora 8s ease-in-out infinite alternate;
|
||||||
@keyframes aurora {
|
@keyframes aurora {
|
||||||
0% {
|
0% {
|
||||||
background-position: 0% 50%;
|
background-position: 0% 50%;
|
||||||
transform: rotate(-5deg) scale(0.9);}
|
transform: rotate(-5deg) scale(0.9);
|
||||||
25% {
|
}
|
||||||
background-position: 50% 100%;
|
25% {
|
||||||
transform: rotate(5deg) scale(1.1);}
|
background-position: 50% 100%;
|
||||||
50% {
|
transform: rotate(5deg) scale(1.1);
|
||||||
background-position: 100% 50%;
|
}
|
||||||
transform: rotate(-3deg) scale(0.95);}
|
50% {
|
||||||
75% {
|
background-position: 100% 50%;
|
||||||
background-position: 50% 0%;
|
transform: rotate(-3deg) scale(0.95);
|
||||||
transform: rotate(3deg) scale(1.05);}
|
}
|
||||||
100% {
|
75% {
|
||||||
background-position: 0% 50%;
|
background-position: 50% 0%;
|
||||||
transform: rotate(-5deg) scale(0.9);}}
|
transform: rotate(3deg) scale(1.05);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
background-position: 0% 50%;
|
||||||
|
transform: rotate(-5deg) scale(0.9);
|
||||||
|
}
|
||||||
|
}
|
||||||
--animate-shiny-text: shiny-text 8s infinite;
|
--animate-shiny-text: shiny-text 8s infinite;
|
||||||
@keyframes shiny-text {
|
@keyframes shiny-text {
|
||||||
0%, 90%, 100% {
|
0%,
|
||||||
background-position: calc(-100% - var(--shiny-width)) 0;}
|
90%,
|
||||||
30%, 60% {
|
100% {
|
||||||
background-position: calc(100% + var(--shiny-width)) 0;}}}
|
background-position: calc(-100% - var(--shiny-width)) 0;
|
||||||
|
}
|
||||||
|
30%,
|
||||||
|
60% {
|
||||||
|
background-position: calc(100% + var(--shiny-width)) 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--radius: 0.625rem;
|
--radius: 0.625rem;
|
||||||
@ -168,7 +188,7 @@
|
|||||||
body {
|
body {
|
||||||
@apply bg-background text-foreground;
|
@apply bg-background text-foreground;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Line clamp utility */
|
/* Line clamp utility */
|
||||||
.line-clamp-2 {
|
.line-clamp-2 {
|
||||||
display: -webkit-box;
|
display: -webkit-box;
|
||||||
@ -176,4 +196,4 @@
|
|||||||
-webkit-box-orient: vertical;
|
-webkit-box-orient: vertical;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -23,8 +23,8 @@ export default function RootLayout({ children }: { children: ReactNode }) {
|
|||||||
<html lang="en" suppressHydrationWarning>
|
<html lang="en" suppressHydrationWarning>
|
||||||
<body className="bg-background text-foreground min-h-screen font-sans antialiased">
|
<body className="bg-background text-foreground min-h-screen font-sans antialiased">
|
||||||
{/* Skip navigation link for keyboard users */}
|
{/* Skip navigation link for keyboard users */}
|
||||||
<a
|
<a
|
||||||
href="#main-content"
|
href="#main-content"
|
||||||
className="sr-only focus:not-sr-only focus:absolute focus:top-4 focus:left-4 focus:z-50 focus:px-4 focus:py-2 focus:bg-primary focus:text-primary-foreground focus:rounded focus:outline-none focus:ring-2 focus:ring-ring"
|
className="sr-only focus:not-sr-only focus:absolute focus:top-4 focus:left-4 focus:z-50 focus:px-4 focus:py-2 focus:bg-primary focus:text-primary-foreground focus:rounded focus:outline-none focus:ring-2 focus:ring-ring"
|
||||||
>
|
>
|
||||||
Skip to main content
|
Skip to main content
|
||||||
|
|||||||
@ -4,7 +4,13 @@ import { signIn } from "next-auth/react";
|
|||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
@ -73,7 +79,8 @@ export default function LoginPage() {
|
|||||||
Welcome back to your analytics dashboard
|
Welcome back to your analytics dashboard
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-xl text-muted-foreground mb-8">
|
<p className="text-xl text-muted-foreground mb-8">
|
||||||
Monitor, analyze, and optimize your customer conversations with AI-powered insights.
|
Monitor, analyze, and optimize your customer conversations with
|
||||||
|
AI-powered insights.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
@ -81,19 +88,25 @@ export default function LoginPage() {
|
|||||||
<div className="p-2 rounded-lg bg-primary/10 text-primary">
|
<div className="p-2 rounded-lg bg-primary/10 text-primary">
|
||||||
<BarChart3 className="h-5 w-5" />
|
<BarChart3 className="h-5 w-5" />
|
||||||
</div>
|
</div>
|
||||||
<span className="text-muted-foreground">Real-time analytics and insights</span>
|
<span className="text-muted-foreground">
|
||||||
|
Real-time analytics and insights
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="p-2 rounded-lg bg-green-500/10 text-green-600">
|
<div className="p-2 rounded-lg bg-green-500/10 text-green-600">
|
||||||
<Shield className="h-5 w-5" />
|
<Shield className="h-5 w-5" />
|
||||||
</div>
|
</div>
|
||||||
<span className="text-muted-foreground">Enterprise-grade security</span>
|
<span className="text-muted-foreground">
|
||||||
|
Enterprise-grade security
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="p-2 rounded-lg bg-blue-500/10 text-blue-600">
|
<div className="p-2 rounded-lg bg-blue-500/10 text-blue-600">
|
||||||
<Zap className="h-5 w-5" />
|
<Zap className="h-5 w-5" />
|
||||||
</div>
|
</div>
|
||||||
<span className="text-muted-foreground">AI-powered conversation analysis</span>
|
<span className="text-muted-foreground">
|
||||||
|
AI-powered conversation analysis
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -130,13 +143,19 @@ export default function LoginPage() {
|
|||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
|
{/* Live region for screen reader announcements */}
|
||||||
|
<div role="status" aria-live="polite" className="sr-only">
|
||||||
|
{isLoading && "Signing in, please wait..."}
|
||||||
|
{error && `Error: ${error}`}
|
||||||
|
</div>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<Alert variant="destructive" className="mb-6">
|
<Alert variant="destructive" className="mb-6" role="alert">
|
||||||
<AlertDescription>{error}</AlertDescription>
|
<AlertDescription>{error}</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<form onSubmit={handleLogin} className="space-y-4">
|
<form onSubmit={handleLogin} className="space-y-4" noValidate>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="email">Email</Label>
|
<Label htmlFor="email">Email</Label>
|
||||||
<Input
|
<Input
|
||||||
@ -147,8 +166,13 @@ export default function LoginPage() {
|
|||||||
onChange={(e) => setEmail(e.target.value)}
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
required
|
required
|
||||||
|
aria-describedby="email-help"
|
||||||
|
aria-invalid={!!error}
|
||||||
className="transition-all duration-200 focus:ring-2 focus:ring-primary/20"
|
className="transition-all duration-200 focus:ring-2 focus:ring-primary/20"
|
||||||
/>
|
/>
|
||||||
|
<div id="email-help" className="sr-only">
|
||||||
|
Enter your company email address
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="password">Password</Label>
|
<Label htmlFor="password">Password</Label>
|
||||||
@ -160,39 +184,57 @@ export default function LoginPage() {
|
|||||||
onChange={(e) => setPassword(e.target.value)}
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
required
|
required
|
||||||
|
aria-describedby="password-help"
|
||||||
|
aria-invalid={!!error}
|
||||||
className="transition-all duration-200 focus:ring-2 focus:ring-primary/20"
|
className="transition-all duration-200 focus:ring-2 focus:ring-primary/20"
|
||||||
/>
|
/>
|
||||||
|
<div id="password-help" className="sr-only">
|
||||||
|
Enter your account password
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
className="w-full mt-6 h-11 bg-linear-to-r from-primary to-primary/90 hover:from-primary/90 hover:to-primary/80 transition-all duration-200"
|
className="w-full mt-6 h-11 bg-linear-to-r from-primary to-primary/90 hover:from-primary/90 hover:to-primary/80 transition-all duration-200"
|
||||||
disabled={isLoading}
|
disabled={isLoading || !email || !password}
|
||||||
|
aria-describedby={isLoading ? "loading-status" : undefined}
|
||||||
>
|
>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<>
|
<>
|
||||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
<Loader2
|
||||||
|
className="mr-2 h-4 w-4 animate-spin"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
Signing in...
|
Signing in...
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
"Sign in"
|
"Sign in"
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
|
{isLoading && (
|
||||||
|
<div
|
||||||
|
id="loading-status"
|
||||||
|
className="sr-only"
|
||||||
|
aria-live="polite"
|
||||||
|
>
|
||||||
|
Authentication in progress, please wait
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<div className="mt-6 space-y-4">
|
<div className="mt-6 space-y-4">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<Link
|
<Link
|
||||||
href="/register"
|
href="/register"
|
||||||
className="text-sm text-primary hover:underline transition-colors"
|
className="text-sm text-primary hover:underline transition-colors focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2 rounded-sm"
|
||||||
>
|
>
|
||||||
Don't have a company account? Register here
|
Don't have a company account? Register here
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<Link
|
<Link
|
||||||
href="/forgot-password"
|
href="/forgot-password"
|
||||||
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
|
className="text-sm text-muted-foreground hover:text-foreground transition-colors focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2 rounded-sm"
|
||||||
>
|
>
|
||||||
Forgot your password?
|
Forgot your password?
|
||||||
</Link>
|
</Link>
|
||||||
@ -203,11 +245,17 @@ export default function LoginPage() {
|
|||||||
|
|
||||||
<p className="mt-8 text-center text-xs text-muted-foreground">
|
<p className="mt-8 text-center text-xs text-muted-foreground">
|
||||||
By signing in, you agree to our{" "}
|
By signing in, you agree to our{" "}
|
||||||
<Link href="/terms" className="text-primary hover:underline">
|
<Link
|
||||||
|
href="/terms"
|
||||||
|
className="text-primary hover:underline focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2 rounded-sm"
|
||||||
|
>
|
||||||
Terms of Service
|
Terms of Service
|
||||||
</Link>{" "}
|
</Link>{" "}
|
||||||
and{" "}
|
and{" "}
|
||||||
<Link href="/privacy" className="text-primary hover:underline">
|
<Link
|
||||||
|
href="/privacy"
|
||||||
|
className="text-primary hover:underline focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2 rounded-sm"
|
||||||
|
>
|
||||||
Privacy Policy
|
Privacy Policy
|
||||||
</Link>
|
</Link>
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@ -36,7 +36,7 @@ const Map = ({ countryData, maxCount }: MapProps) => {
|
|||||||
const tileLayerUrl = isDark
|
const tileLayerUrl = isDark
|
||||||
? "https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png"
|
? "https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png"
|
||||||
: "https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png";
|
: "https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png";
|
||||||
|
|
||||||
const tileLayerAttribution = isDark
|
const tileLayerAttribution = isDark
|
||||||
? '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors © <a href="https://carto.com/attributions">CARTO</a>'
|
? '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors © <a href="https://carto.com/attributions">CARTO</a>'
|
||||||
: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors © <a href="https://carto.com/attributions">CARTO</a>';
|
: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors © <a href="https://carto.com/attributions">CARTO</a>';
|
||||||
@ -49,10 +49,7 @@ const Map = ({ countryData, maxCount }: MapProps) => {
|
|||||||
scrollWheelZoom={false}
|
scrollWheelZoom={false}
|
||||||
style={{ height: "100%", width: "100%", borderRadius: "0.5rem" }}
|
style={{ height: "100%", width: "100%", borderRadius: "0.5rem" }}
|
||||||
>
|
>
|
||||||
<TileLayer
|
<TileLayer attribution={tileLayerAttribution} url={tileLayerUrl} />
|
||||||
attribution={tileLayerAttribution}
|
|
||||||
url={tileLayerUrl}
|
|
||||||
/>
|
|
||||||
{countryData.map((country) => (
|
{countryData.map((country) => (
|
||||||
<CircleMarker
|
<CircleMarker
|
||||||
key={country.code}
|
key={country.code}
|
||||||
@ -71,7 +68,9 @@ const Map = ({ countryData, maxCount }: MapProps) => {
|
|||||||
<div className="font-medium text-foreground">
|
<div className="font-medium text-foreground">
|
||||||
{getLocalizedCountryName(country.code)}
|
{getLocalizedCountryName(country.code)}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm text-muted-foreground">Sessions: {country.count}</div>
|
<div className="text-sm text-muted-foreground">
|
||||||
|
Sessions: {country.count}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</CircleMarker>
|
</CircleMarker>
|
||||||
|
|||||||
@ -114,9 +114,9 @@ export default function ResponseTimeDistribution({
|
|||||||
/>
|
/>
|
||||||
<Tooltip content={<CustomTooltip />} />
|
<Tooltip content={<CustomTooltip />} />
|
||||||
|
|
||||||
<Bar
|
<Bar
|
||||||
dataKey="value"
|
dataKey="value"
|
||||||
radius={[4, 4, 0, 0]}
|
radius={[4, 4, 0, 0]}
|
||||||
fill="hsl(var(--chart-1))"
|
fill="hsl(var(--chart-1))"
|
||||||
maxBarSize={60}
|
maxBarSize={60}
|
||||||
>
|
>
|
||||||
|
|||||||
@ -97,7 +97,8 @@ export default function SessionDetails({ session }: SessionDetailsProps) {
|
|||||||
: "secondary"
|
: "secondary"
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{session.sentiment.charAt(0).toUpperCase() + session.sentiment.slice(1)}
|
{session.sentiment.charAt(0).toUpperCase() +
|
||||||
|
session.sentiment.slice(1)}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -107,12 +108,17 @@ export default function SessionDetails({ session }: SessionDetailsProps) {
|
|||||||
<p className="font-medium">{session.messagesSent || 0}</p>
|
<p className="font-medium">{session.messagesSent || 0}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{session.avgResponseTime !== null && session.avgResponseTime !== undefined && (
|
{session.avgResponseTime !== null &&
|
||||||
<div>
|
session.avgResponseTime !== undefined && (
|
||||||
<p className="text-sm text-muted-foreground">Avg Response Time</p>
|
<div>
|
||||||
<p className="font-medium">{session.avgResponseTime.toFixed(2)}s</p>
|
<p className="text-sm text-muted-foreground">
|
||||||
</div>
|
Avg Response Time
|
||||||
)}
|
</p>
|
||||||
|
<p className="font-medium">
|
||||||
|
{session.avgResponseTime.toFixed(2)}s
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{session.escalated !== null && session.escalated !== undefined && (
|
{session.escalated !== null && session.escalated !== undefined && (
|
||||||
<div>
|
<div>
|
||||||
@ -123,14 +129,19 @@ export default function SessionDetails({ session }: SessionDetailsProps) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{session.forwardedHr !== null && session.forwardedHr !== undefined && (
|
{session.forwardedHr !== null &&
|
||||||
<div>
|
session.forwardedHr !== undefined && (
|
||||||
<p className="text-sm text-muted-foreground">Forwarded to HR</p>
|
<div>
|
||||||
<Badge variant={session.forwardedHr ? "secondary" : "default"}>
|
<p className="text-sm text-muted-foreground">
|
||||||
{session.forwardedHr ? "Yes" : "No"}
|
Forwarded to HR
|
||||||
</Badge>
|
</p>
|
||||||
</div>
|
<Badge
|
||||||
)}
|
variant={session.forwardedHr ? "secondary" : "default"}
|
||||||
|
>
|
||||||
|
{session.forwardedHr ? "Yes" : "No"}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{session.ipAddress && (
|
{session.ipAddress && (
|
||||||
<div>
|
<div>
|
||||||
@ -156,7 +167,9 @@ export default function SessionDetails({ session }: SessionDetailsProps) {
|
|||||||
|
|
||||||
{!session.summary && session.initialMsg && (
|
{!session.summary && session.initialMsg && (
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm text-muted-foreground mb-2">Initial Message</p>
|
<p className="text-sm text-muted-foreground mb-2">
|
||||||
|
Initial Message
|
||||||
|
</p>
|
||||||
<div className="bg-muted p-3 rounded-md text-sm italic">
|
<div className="bg-muted p-3 rounded-md text-sm italic">
|
||||||
"{session.initialMsg}"
|
"{session.initialMsg}"
|
||||||
</div>
|
</div>
|
||||||
@ -171,9 +184,10 @@ export default function SessionDetails({ session }: SessionDetailsProps) {
|
|||||||
href={session.fullTranscriptUrl}
|
href={session.fullTranscriptUrl}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="inline-flex items-center gap-2 text-primary hover:underline"
|
className="inline-flex items-center gap-2 text-primary hover:underline focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2 rounded-sm"
|
||||||
|
aria-label="Open full transcript in new tab"
|
||||||
>
|
>
|
||||||
<ExternalLink className="h-4 w-4" />
|
<ExternalLink className="h-4 w-4" aria-hidden="true" />
|
||||||
View Full Transcript
|
View Full Transcript
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -337,8 +337,14 @@ export default function Sidebar({
|
|||||||
</nav>
|
</nav>
|
||||||
<div className="p-4 border-t mt-auto space-y-2">
|
<div className="p-4 border-t mt-auto space-y-2">
|
||||||
{/* Theme Toggle */}
|
{/* Theme Toggle */}
|
||||||
<div className={`flex items-center ${isExpanded ? "justify-between" : "justify-center"}`}>
|
<div
|
||||||
{isExpanded && <span className="text-sm font-medium text-muted-foreground">Theme</span>}
|
className={`flex items-center ${isExpanded ? "justify-between" : "justify-center"}`}
|
||||||
|
>
|
||||||
|
{isExpanded && (
|
||||||
|
<span className="text-sm font-medium text-muted-foreground">
|
||||||
|
Theme
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
<SimpleThemeToggle />
|
<SimpleThemeToggle />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@ -92,7 +92,11 @@ export default function ModernDonutChart({
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
)}
|
)}
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="relative">
|
<div
|
||||||
|
className="relative"
|
||||||
|
role="img"
|
||||||
|
aria-label={`${title || "Chart"} - ${data.length} segments`}
|
||||||
|
>
|
||||||
<ResponsiveContainer width="100%" height={height}>
|
<ResponsiveContainer width="100%" height={height}>
|
||||||
<PieChart>
|
<PieChart>
|
||||||
<Pie
|
<Pie
|
||||||
@ -103,13 +107,19 @@ export default function ModernDonutChart({
|
|||||||
outerRadius={100}
|
outerRadius={100}
|
||||||
paddingAngle={2}
|
paddingAngle={2}
|
||||||
dataKey="value"
|
dataKey="value"
|
||||||
className="transition-all duration-200"
|
className="transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-primary"
|
||||||
|
tabIndex={0}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" || e.key === " ") {
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{dataWithTotal.map((entry, index) => (
|
{dataWithTotal.map((entry, index) => (
|
||||||
<Cell
|
<Cell
|
||||||
key={`cell-${index}`}
|
key={`cell-${index}`}
|
||||||
fill={entry.color || colors[index % colors.length]}
|
fill={entry.color || colors[index % colors.length]}
|
||||||
className="hover:opacity-80 cursor-pointer"
|
className="hover:opacity-80 cursor-pointer focus:opacity-80"
|
||||||
stroke="hsl(var(--background))"
|
stroke="hsl(var(--background))"
|
||||||
strokeWidth={2}
|
strokeWidth={2}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -130,7 +130,7 @@ export const AnimatedBeam: React.FC<AnimatedBeamProps> = ({
|
|||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
className={cn(
|
className={cn(
|
||||||
"pointer-events-none absolute left-0 top-0 transform-gpu stroke-2",
|
"pointer-events-none absolute left-0 top-0 transform-gpu stroke-2",
|
||||||
className,
|
className
|
||||||
)}
|
)}
|
||||||
viewBox={`0 0 ${svgDimensions.width} ${svgDimensions.height}`}
|
viewBox={`0 0 ${svgDimensions.width} ${svgDimensions.height}`}
|
||||||
>
|
>
|
||||||
|
|||||||
@ -29,7 +29,7 @@ export const AnimatedShinyText: FC<AnimatedShinyTextProps> = ({
|
|||||||
// Shine gradient
|
// Shine gradient
|
||||||
"bg-gradient-to-r from-transparent via-black/80 via-50% to-transparent dark:via-white/80",
|
"bg-gradient-to-r from-transparent via-black/80 via-50% to-transparent dark:via-white/80",
|
||||||
|
|
||||||
className,
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
|
|||||||
@ -37,7 +37,7 @@ export const AuroraText = memo(
|
|||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
},
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
AuroraText.displayName = "AuroraText";
|
AuroraText.displayName = "AuroraText";
|
||||||
|
|||||||
@ -66,15 +66,17 @@ export const BorderBeam = ({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="pointer-events-none absolute inset-0 rounded-[inherit] border-transparent [mask-clip:padding-box,border-box] [mask-composite:intersect] [mask-image:linear-gradient(transparent,transparent),linear-gradient(#000,#000)] border-(length:--border-beam-width)"
|
className="pointer-events-none absolute inset-0 rounded-[inherit] border-transparent [mask-clip:padding-box,border-box] [mask-composite:intersect] [mask-image:linear-gradient(transparent,transparent),linear-gradient(#000,#000)] border-(length:--border-beam-width)"
|
||||||
style={{
|
style={
|
||||||
"--border-beam-width": `${borderWidth}px`,
|
{
|
||||||
} as React.CSSProperties}
|
"--border-beam-width": `${borderWidth}px`,
|
||||||
|
} as React.CSSProperties
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<motion.div
|
<motion.div
|
||||||
className={cn(
|
className={cn(
|
||||||
"absolute aspect-square",
|
"absolute aspect-square",
|
||||||
"bg-gradient-to-l from-[var(--color-from)] via-[var(--color-to)] to-transparent",
|
"bg-gradient-to-l from-[var(--color-from)] via-[var(--color-to)] to-transparent",
|
||||||
className,
|
className
|
||||||
)}
|
)}
|
||||||
style={
|
style={
|
||||||
{
|
{
|
||||||
|
|||||||
@ -60,7 +60,7 @@ const ConfettiComponent = forwardRef<ConfettiRef, Props>((props, ref) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[globalOptions],
|
[globalOptions]
|
||||||
);
|
);
|
||||||
|
|
||||||
const fire = useCallback(
|
const fire = useCallback(
|
||||||
@ -71,14 +71,14 @@ const ConfettiComponent = forwardRef<ConfettiRef, Props>((props, ref) => {
|
|||||||
console.error("Confetti error:", error);
|
console.error("Confetti error:", error);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[options],
|
[options]
|
||||||
);
|
);
|
||||||
|
|
||||||
const api = useMemo(
|
const api = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
fire,
|
fire,
|
||||||
}),
|
}),
|
||||||
[fire],
|
[fire]
|
||||||
);
|
);
|
||||||
|
|
||||||
useImperativeHandle(ref, () => api, [api]);
|
useImperativeHandle(ref, () => api, [api]);
|
||||||
|
|||||||
@ -38,7 +38,7 @@ export function MagicCard({
|
|||||||
mouseY.set(clientY - top);
|
mouseY.set(clientY - top);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[mouseX, mouseY],
|
[mouseX, mouseY]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleMouseOut = useCallback(
|
const handleMouseOut = useCallback(
|
||||||
@ -49,7 +49,7 @@ export function MagicCard({
|
|||||||
mouseY.set(-gradientSize);
|
mouseY.set(-gradientSize);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[handleMouseMove, mouseX, gradientSize, mouseY],
|
[handleMouseMove, mouseX, gradientSize, mouseY]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleMouseEnter = useCallback(() => {
|
const handleMouseEnter = useCallback(() => {
|
||||||
|
|||||||
@ -23,7 +23,7 @@ export const Meteors = ({
|
|||||||
className,
|
className,
|
||||||
}: MeteorsProps) => {
|
}: MeteorsProps) => {
|
||||||
const [meteorStyles, setMeteorStyles] = useState<Array<React.CSSProperties>>(
|
const [meteorStyles, setMeteorStyles] = useState<Array<React.CSSProperties>>(
|
||||||
[],
|
[]
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -48,7 +48,7 @@ export const Meteors = ({
|
|||||||
style={{ ...style }}
|
style={{ ...style }}
|
||||||
className={cn(
|
className={cn(
|
||||||
"pointer-events-none absolute size-0.5 rotate-[var(--angle)] animate-meteor rounded-full bg-zinc-500 shadow-[0_0_0_1px_#ffffff10]",
|
"pointer-events-none absolute size-0.5 rotate-[var(--angle)] animate-meteor rounded-full bg-zinc-500 shadow-[0_0_0_1px_#ffffff10]",
|
||||||
className,
|
className
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{/* Meteor Tail */}
|
{/* Meteor Tail */}
|
||||||
|
|||||||
@ -124,7 +124,7 @@ export const NeonGradientCard: React.FC<NeonGradientCardProps> = ({
|
|||||||
}
|
}
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative z-10 size-full rounded-[var(--border-radius)]",
|
"relative z-10 size-full rounded-[var(--border-radius)]",
|
||||||
className,
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
@ -139,7 +139,7 @@ export const NeonGradientCard: React.FC<NeonGradientCardProps> = ({
|
|||||||
"after:h-[var(--pseudo-element-height)] after:w-[var(--pseudo-element-width)] after:rounded-[var(--border-radius)] after:blur-[var(--after-blur)] after:content-['']",
|
"after:h-[var(--pseudo-element-height)] after:w-[var(--pseudo-element-width)] after:rounded-[var(--border-radius)] after:blur-[var(--after-blur)] after:content-['']",
|
||||||
"after:bg-[linear-gradient(0deg,var(--neon-first-color),var(--neon-second-color))] after:bg-[length:100%_200%] after:opacity-80",
|
"after:bg-[linear-gradient(0deg,var(--neon-first-color),var(--neon-second-color))] after:bg-[length:100%_200%] after:opacity-80",
|
||||||
"after:animate-background-position-spin",
|
"after:animate-background-position-spin",
|
||||||
"dark:bg-neutral-900",
|
"dark:bg-neutral-900"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@ -49,7 +49,7 @@ export function NumberTicker({
|
|||||||
}).format(Number(latest.toFixed(decimalPlaces)));
|
}).format(Number(latest.toFixed(decimalPlaces)));
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
[springValue, decimalPlaces],
|
[springValue, decimalPlaces]
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -57,7 +57,7 @@ export function NumberTicker({
|
|||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"inline-block tabular-nums tracking-wider text-black dark:text-white",
|
"inline-block tabular-nums tracking-wider text-black dark:text-white",
|
||||||
className,
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
|
|||||||
@ -9,7 +9,9 @@ import {
|
|||||||
} from "motion/react";
|
} from "motion/react";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
interface PointerProps extends Omit<HTMLMotionProps<"div">, "ref"> {}
|
interface PointerProps extends Omit<HTMLMotionProps<"div">, "ref"> {
|
||||||
|
children?: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A custom pointer component that displays an animated cursor.
|
* A custom pointer component that displays an animated cursor.
|
||||||
@ -104,7 +106,7 @@ export function Pointer({
|
|||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
className={cn(
|
className={cn(
|
||||||
"rotate-[-70deg] stroke-white text-black",
|
"rotate-[-70deg] stroke-white text-black",
|
||||||
className,
|
className
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<path d="M14.082 2.182a.5.5 0 0 1 .103.557L8.528 15.467a.5.5 0 0 1-.917-.007L5.57 10.694.803 8.652a.5.5 0 0 1-.006-.916l12.728-5.657a.5.5 0 0 1 .556.103z" />
|
<path d="M14.082 2.182a.5.5 0 0 1 .103.557L8.528 15.467a.5.5 0 0 1-.917-.007L5.57 10.694.803 8.652a.5.5 0 0 1-.006-.916l12.728-5.657a.5.5 0 0 1 .556.103z" />
|
||||||
|
|||||||
@ -4,7 +4,9 @@ import { cn } from "@/lib/utils";
|
|||||||
import { motion, MotionProps, useScroll } from "motion/react";
|
import { motion, MotionProps, useScroll } from "motion/react";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
interface ScrollProgressProps
|
interface ScrollProgressProps
|
||||||
extends Omit<React.HTMLAttributes<HTMLElement>, keyof MotionProps> {}
|
extends Omit<React.HTMLAttributes<HTMLElement>, keyof MotionProps> {
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export const ScrollProgress = React.forwardRef<
|
export const ScrollProgress = React.forwardRef<
|
||||||
HTMLDivElement,
|
HTMLDivElement,
|
||||||
@ -17,7 +19,7 @@ export const ScrollProgress = React.forwardRef<
|
|||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"fixed inset-x-0 top-0 z-50 h-px origin-left bg-gradient-to-r from-[#A97CF8] via-[#F38CB8] to-[#FDCC92]",
|
"fixed inset-x-0 top-0 z-50 h-px origin-left bg-gradient-to-r from-[#A97CF8] via-[#F38CB8] to-[#FDCC92]",
|
||||||
className,
|
className
|
||||||
)}
|
)}
|
||||||
style={{
|
style={{
|
||||||
scaleX: scrollYProgress,
|
scaleX: scrollYProgress,
|
||||||
|
|||||||
@ -55,7 +55,7 @@ export function ShineBorder({
|
|||||||
}
|
}
|
||||||
className={cn(
|
className={cn(
|
||||||
"pointer-events-none absolute inset-0 size-full rounded-[inherit] will-change-[background-position] motion-safe:animate-shine",
|
"pointer-events-none absolute inset-0 size-full rounded-[inherit] will-change-[background-position] motion-safe:animate-shine",
|
||||||
className,
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -395,7 +395,7 @@ const TextAnimateBase = ({
|
|||||||
className={cn(
|
className={cn(
|
||||||
by === "line" ? "block" : "inline-block whitespace-pre",
|
by === "line" ? "block" : "inline-block whitespace-pre",
|
||||||
by === "character" && "",
|
by === "character" && "",
|
||||||
segmentClassName,
|
segmentClassName
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{segment}
|
{segment}
|
||||||
|
|||||||
@ -6,4 +6,4 @@ import { type ThemeProviderProps } from "next-themes/dist/types";
|
|||||||
|
|
||||||
export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
|
export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
|
||||||
return <NextThemesProvider {...props}>{children}</NextThemesProvider>;
|
return <NextThemesProvider {...props}>{children}</NextThemesProvider>;
|
||||||
}
|
}
|
||||||
|
|||||||
66
components/ui/accordion.tsx
Normal file
66
components/ui/accordion.tsx
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import * as AccordionPrimitive from "@radix-ui/react-accordion";
|
||||||
|
import { ChevronDownIcon } from "lucide-react";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
function Accordion({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AccordionPrimitive.Root>) {
|
||||||
|
return <AccordionPrimitive.Root data-slot="accordion" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function AccordionItem({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AccordionPrimitive.Item>) {
|
||||||
|
return (
|
||||||
|
<AccordionPrimitive.Item
|
||||||
|
data-slot="accordion-item"
|
||||||
|
className={cn("border-b last:border-b-0", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AccordionTrigger({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AccordionPrimitive.Trigger>) {
|
||||||
|
return (
|
||||||
|
<AccordionPrimitive.Header className="flex">
|
||||||
|
<AccordionPrimitive.Trigger
|
||||||
|
data-slot="accordion-trigger"
|
||||||
|
className={cn(
|
||||||
|
"focus-visible:border-ring focus-visible:ring-ring/50 flex flex-1 items-start justify-between gap-4 rounded-md py-4 text-left text-sm font-medium transition-all outline-none hover:underline focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 [&[data-state=open]>svg]:rotate-180",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<ChevronDownIcon className="text-muted-foreground pointer-events-none size-4 shrink-0 translate-y-0.5 transition-transform duration-200" />
|
||||||
|
</AccordionPrimitive.Trigger>
|
||||||
|
</AccordionPrimitive.Header>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AccordionContent({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AccordionPrimitive.Content>) {
|
||||||
|
return (
|
||||||
|
<AccordionPrimitive.Content
|
||||||
|
data-slot="accordion-content"
|
||||||
|
className="data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down overflow-hidden text-sm"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<div className={cn("pt-0 pb-4", className)}>{children}</div>
|
||||||
|
</AccordionPrimitive.Content>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent };
|
||||||
157
components/ui/alert-dialog.tsx
Normal file
157
components/ui/alert-dialog.tsx
Normal file
@ -0,0 +1,157 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { buttonVariants } from "@/components/ui/button";
|
||||||
|
|
||||||
|
function AlertDialog({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Root>) {
|
||||||
|
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogTrigger({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogPortal({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogOverlay({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Overlay>) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Overlay
|
||||||
|
data-slot="alert-dialog-overlay"
|
||||||
|
className={cn(
|
||||||
|
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogContent({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Content>) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPortal>
|
||||||
|
<AlertDialogOverlay />
|
||||||
|
<AlertDialogPrimitive.Content
|
||||||
|
data-slot="alert-dialog-content"
|
||||||
|
className={cn(
|
||||||
|
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</AlertDialogPortal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogHeader({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="alert-dialog-header"
|
||||||
|
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogFooter({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="alert-dialog-footer"
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogTitle({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Title
|
||||||
|
data-slot="alert-dialog-title"
|
||||||
|
className={cn("text-lg font-semibold", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogDescription({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Description
|
||||||
|
data-slot="alert-dialog-description"
|
||||||
|
className={cn("text-muted-foreground text-sm", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogAction({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Action>) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Action
|
||||||
|
className={cn(buttonVariants(), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogCancel({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Cancel>) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Cancel
|
||||||
|
className={cn(buttonVariants({ variant: "outline" }), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogPortal,
|
||||||
|
AlertDialogOverlay,
|
||||||
|
AlertDialogTrigger,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogTitle,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
};
|
||||||
@ -56,4 +56,4 @@ const AlertDescription = React.forwardRef<
|
|||||||
));
|
));
|
||||||
AlertDescription.displayName = "AlertDescription";
|
AlertDescription.displayName = "AlertDescription";
|
||||||
|
|
||||||
export { Alert, AlertTitle, AlertDescription };
|
export { Alert, AlertTitle, AlertDescription };
|
||||||
|
|||||||
109
components/ui/breadcrumb.tsx
Normal file
109
components/ui/breadcrumb.tsx
Normal file
@ -0,0 +1,109 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import { Slot } from "@radix-ui/react-slot";
|
||||||
|
import { ChevronRight, MoreHorizontal } from "lucide-react";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
function Breadcrumb({ ...props }: React.ComponentProps<"nav">) {
|
||||||
|
return <nav aria-label="breadcrumb" data-slot="breadcrumb" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function BreadcrumbList({ className, ...props }: React.ComponentProps<"ol">) {
|
||||||
|
return (
|
||||||
|
<ol
|
||||||
|
data-slot="breadcrumb-list"
|
||||||
|
className={cn(
|
||||||
|
"text-muted-foreground flex flex-wrap items-center gap-1.5 text-sm break-words sm:gap-2.5",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function BreadcrumbItem({ className, ...props }: React.ComponentProps<"li">) {
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
data-slot="breadcrumb-item"
|
||||||
|
className={cn("inline-flex items-center gap-1.5", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function BreadcrumbLink({
|
||||||
|
asChild,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"a"> & {
|
||||||
|
asChild?: boolean;
|
||||||
|
}) {
|
||||||
|
const Comp = asChild ? Slot : "a";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
data-slot="breadcrumb-link"
|
||||||
|
className={cn("hover:text-foreground transition-colors", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function BreadcrumbPage({ className, ...props }: React.ComponentProps<"span">) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
data-slot="breadcrumb-page"
|
||||||
|
role="link"
|
||||||
|
aria-disabled="true"
|
||||||
|
aria-current="page"
|
||||||
|
className={cn("text-foreground font-normal", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function BreadcrumbSeparator({
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"li">) {
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
data-slot="breadcrumb-separator"
|
||||||
|
role="presentation"
|
||||||
|
aria-hidden="true"
|
||||||
|
className={cn("[&>svg]:size-3.5", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children ?? <ChevronRight />}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function BreadcrumbEllipsis({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"span">) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
data-slot="breadcrumb-ellipsis"
|
||||||
|
role="presentation"
|
||||||
|
aria-hidden="true"
|
||||||
|
className={cn("flex size-9 items-center justify-center", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<MoreHorizontal className="size-4" />
|
||||||
|
<span className="sr-only">More</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Breadcrumb,
|
||||||
|
BreadcrumbList,
|
||||||
|
BreadcrumbItem,
|
||||||
|
BreadcrumbLink,
|
||||||
|
BreadcrumbPage,
|
||||||
|
BreadcrumbSeparator,
|
||||||
|
BreadcrumbEllipsis,
|
||||||
|
};
|
||||||
210
components/ui/calendar.tsx
Normal file
210
components/ui/calendar.tsx
Normal file
@ -0,0 +1,210 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import {
|
||||||
|
ChevronDownIcon,
|
||||||
|
ChevronLeftIcon,
|
||||||
|
ChevronRightIcon,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { DayButton, DayPicker, getDefaultClassNames } from "react-day-picker";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { Button, buttonVariants } from "@/components/ui/button";
|
||||||
|
|
||||||
|
function Calendar({
|
||||||
|
className,
|
||||||
|
classNames,
|
||||||
|
showOutsideDays = true,
|
||||||
|
captionLayout = "label",
|
||||||
|
buttonVariant = "ghost",
|
||||||
|
formatters,
|
||||||
|
components,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DayPicker> & {
|
||||||
|
buttonVariant?: React.ComponentProps<typeof Button>["variant"];
|
||||||
|
}) {
|
||||||
|
const defaultClassNames = getDefaultClassNames();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DayPicker
|
||||||
|
showOutsideDays={showOutsideDays}
|
||||||
|
className={cn(
|
||||||
|
"bg-background group/calendar p-3 [--cell-size:--spacing(8)] [[data-slot=card-content]_&]:bg-transparent [[data-slot=popover-content]_&]:bg-transparent",
|
||||||
|
String.raw`rtl:**:[.rdp-button\_next>svg]:rotate-180`,
|
||||||
|
String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`,
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
captionLayout={captionLayout}
|
||||||
|
formatters={{
|
||||||
|
formatMonthDropdown: (date) =>
|
||||||
|
date.toLocaleString("default", { month: "short" }),
|
||||||
|
...formatters,
|
||||||
|
}}
|
||||||
|
classNames={{
|
||||||
|
root: cn("w-fit", defaultClassNames.root),
|
||||||
|
months: cn(
|
||||||
|
"flex gap-4 flex-col md:flex-row relative",
|
||||||
|
defaultClassNames.months
|
||||||
|
),
|
||||||
|
month: cn("flex flex-col w-full gap-4", defaultClassNames.month),
|
||||||
|
nav: cn(
|
||||||
|
"flex items-center gap-1 w-full absolute top-0 inset-x-0 justify-between",
|
||||||
|
defaultClassNames.nav
|
||||||
|
),
|
||||||
|
button_previous: cn(
|
||||||
|
buttonVariants({ variant: buttonVariant }),
|
||||||
|
"size-(--cell-size) aria-disabled:opacity-50 p-0 select-none",
|
||||||
|
defaultClassNames.button_previous
|
||||||
|
),
|
||||||
|
button_next: cn(
|
||||||
|
buttonVariants({ variant: buttonVariant }),
|
||||||
|
"size-(--cell-size) aria-disabled:opacity-50 p-0 select-none",
|
||||||
|
defaultClassNames.button_next
|
||||||
|
),
|
||||||
|
month_caption: cn(
|
||||||
|
"flex items-center justify-center h-(--cell-size) w-full px-(--cell-size)",
|
||||||
|
defaultClassNames.month_caption
|
||||||
|
),
|
||||||
|
dropdowns: cn(
|
||||||
|
"w-full flex items-center text-sm font-medium justify-center h-(--cell-size) gap-1.5",
|
||||||
|
defaultClassNames.dropdowns
|
||||||
|
),
|
||||||
|
dropdown_root: cn(
|
||||||
|
"relative has-focus:border-ring border border-input shadow-xs has-focus:ring-ring/50 has-focus:ring-[3px] rounded-md",
|
||||||
|
defaultClassNames.dropdown_root
|
||||||
|
),
|
||||||
|
dropdown: cn("absolute inset-0 opacity-0", defaultClassNames.dropdown),
|
||||||
|
caption_label: cn(
|
||||||
|
"select-none font-medium",
|
||||||
|
captionLayout === "label"
|
||||||
|
? "text-sm"
|
||||||
|
: "rounded-md pl-2 pr-1 flex items-center gap-1 text-sm h-8 [&>svg]:text-muted-foreground [&>svg]:size-3.5",
|
||||||
|
defaultClassNames.caption_label
|
||||||
|
),
|
||||||
|
table: "w-full border-collapse",
|
||||||
|
weekdays: cn("flex", defaultClassNames.weekdays),
|
||||||
|
weekday: cn(
|
||||||
|
"text-muted-foreground rounded-md flex-1 font-normal text-[0.8rem] select-none",
|
||||||
|
defaultClassNames.weekday
|
||||||
|
),
|
||||||
|
week: cn("flex w-full mt-2", defaultClassNames.week),
|
||||||
|
week_number_header: cn(
|
||||||
|
"select-none w-(--cell-size)",
|
||||||
|
defaultClassNames.week_number_header
|
||||||
|
),
|
||||||
|
week_number: cn(
|
||||||
|
"text-[0.8rem] select-none text-muted-foreground",
|
||||||
|
defaultClassNames.week_number
|
||||||
|
),
|
||||||
|
day: cn(
|
||||||
|
"relative w-full h-full p-0 text-center [&:first-child[data-selected=true]_button]:rounded-l-md [&:last-child[data-selected=true]_button]:rounded-r-md group/day aspect-square select-none",
|
||||||
|
defaultClassNames.day
|
||||||
|
),
|
||||||
|
range_start: cn(
|
||||||
|
"rounded-l-md bg-accent",
|
||||||
|
defaultClassNames.range_start
|
||||||
|
),
|
||||||
|
range_middle: cn("rounded-none", defaultClassNames.range_middle),
|
||||||
|
range_end: cn("rounded-r-md bg-accent", defaultClassNames.range_end),
|
||||||
|
today: cn(
|
||||||
|
"bg-accent text-accent-foreground rounded-md data-[selected=true]:rounded-none",
|
||||||
|
defaultClassNames.today
|
||||||
|
),
|
||||||
|
outside: cn(
|
||||||
|
"text-muted-foreground aria-selected:text-muted-foreground",
|
||||||
|
defaultClassNames.outside
|
||||||
|
),
|
||||||
|
disabled: cn(
|
||||||
|
"text-muted-foreground opacity-50",
|
||||||
|
defaultClassNames.disabled
|
||||||
|
),
|
||||||
|
hidden: cn("invisible", defaultClassNames.hidden),
|
||||||
|
...classNames,
|
||||||
|
}}
|
||||||
|
components={{
|
||||||
|
Root: ({ className, rootRef, ...props }) => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="calendar"
|
||||||
|
ref={rootRef}
|
||||||
|
className={cn(className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
Chevron: ({ className, orientation, ...props }) => {
|
||||||
|
if (orientation === "left") {
|
||||||
|
return (
|
||||||
|
<ChevronLeftIcon className={cn("size-4", className)} {...props} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (orientation === "right") {
|
||||||
|
return (
|
||||||
|
<ChevronRightIcon
|
||||||
|
className={cn("size-4", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ChevronDownIcon className={cn("size-4", className)} {...props} />
|
||||||
|
);
|
||||||
|
},
|
||||||
|
DayButton: CalendarDayButton,
|
||||||
|
WeekNumber: ({ children, ...props }) => {
|
||||||
|
return (
|
||||||
|
<td {...props}>
|
||||||
|
<div className="flex size-(--cell-size) items-center justify-center text-center">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
...components,
|
||||||
|
}}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CalendarDayButton({
|
||||||
|
className,
|
||||||
|
day,
|
||||||
|
modifiers,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DayButton>) {
|
||||||
|
const defaultClassNames = getDefaultClassNames();
|
||||||
|
|
||||||
|
const ref = React.useRef<HTMLButtonElement>(null);
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (modifiers.focused) ref.current?.focus();
|
||||||
|
}, [modifiers.focused]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
ref={ref}
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
data-day={day.date.toLocaleDateString()}
|
||||||
|
data-selected-single={
|
||||||
|
modifiers.selected &&
|
||||||
|
!modifiers.range_start &&
|
||||||
|
!modifiers.range_end &&
|
||||||
|
!modifiers.range_middle
|
||||||
|
}
|
||||||
|
data-range-start={modifiers.range_start}
|
||||||
|
data-range-end={modifiers.range_end}
|
||||||
|
data-range-middle={modifiers.range_middle}
|
||||||
|
className={cn(
|
||||||
|
"data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground data-[range-middle=true]:bg-accent data-[range-middle=true]:text-accent-foreground data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-ring/50 dark:hover:text-accent-foreground flex aspect-square size-auto w-full min-w-(--cell-size) flex-col gap-1 leading-none font-normal group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:ring-[3px] data-[range-end=true]:rounded-md data-[range-end=true]:rounded-r-md data-[range-middle=true]:rounded-none data-[range-start=true]:rounded-md data-[range-start=true]:rounded-l-md [&>span]:text-xs [&>span]:opacity-70",
|
||||||
|
defaultClassNames.day,
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Calendar, CalendarDayButton };
|
||||||
33
components/ui/collapsible.tsx
Normal file
33
components/ui/collapsible.tsx
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible";
|
||||||
|
|
||||||
|
function Collapsible({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof CollapsiblePrimitive.Root>) {
|
||||||
|
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function CollapsibleTrigger({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleTrigger>) {
|
||||||
|
return (
|
||||||
|
<CollapsiblePrimitive.CollapsibleTrigger
|
||||||
|
data-slot="collapsible-trigger"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CollapsibleContent({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleContent>) {
|
||||||
|
return (
|
||||||
|
<CollapsiblePrimitive.CollapsibleContent
|
||||||
|
data-slot="collapsible-content"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Collapsible, CollapsibleTrigger, CollapsibleContent };
|
||||||
143
components/ui/dialog.tsx
Normal file
143
components/ui/dialog.tsx
Normal file
@ -0,0 +1,143 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
||||||
|
import { XIcon } from "lucide-react";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
function Dialog({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
|
||||||
|
return <DialogPrimitive.Root data-slot="dialog" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogTrigger({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
|
||||||
|
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogPortal({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
|
||||||
|
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogClose({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
|
||||||
|
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogOverlay({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
|
||||||
|
return (
|
||||||
|
<DialogPrimitive.Overlay
|
||||||
|
data-slot="dialog-overlay"
|
||||||
|
className={cn(
|
||||||
|
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogContent({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
showCloseButton = true,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
|
||||||
|
showCloseButton?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<DialogPortal data-slot="dialog-portal">
|
||||||
|
<DialogOverlay />
|
||||||
|
<DialogPrimitive.Content
|
||||||
|
data-slot="dialog-content"
|
||||||
|
className={cn(
|
||||||
|
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
{showCloseButton && (
|
||||||
|
<DialogPrimitive.Close
|
||||||
|
data-slot="dialog-close"
|
||||||
|
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
|
||||||
|
>
|
||||||
|
<XIcon />
|
||||||
|
<span className="sr-only">Close</span>
|
||||||
|
</DialogPrimitive.Close>
|
||||||
|
)}
|
||||||
|
</DialogPrimitive.Content>
|
||||||
|
</DialogPortal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="dialog-header"
|
||||||
|
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="dialog-footer"
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogTitle({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
|
||||||
|
return (
|
||||||
|
<DialogPrimitive.Title
|
||||||
|
data-slot="dialog-title"
|
||||||
|
className={cn("text-lg leading-none font-semibold", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogDescription({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
|
||||||
|
return (
|
||||||
|
<DialogPrimitive.Description
|
||||||
|
data-slot="dialog-description"
|
||||||
|
className={cn("text-muted-foreground text-sm", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Dialog,
|
||||||
|
DialogClose,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogOverlay,
|
||||||
|
DialogPortal,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
};
|
||||||
135
components/ui/drawer.tsx
Normal file
135
components/ui/drawer.tsx
Normal file
@ -0,0 +1,135 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import { Drawer as DrawerPrimitive } from "vaul";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
function Drawer({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DrawerPrimitive.Root>) {
|
||||||
|
return <DrawerPrimitive.Root data-slot="drawer" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function DrawerTrigger({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DrawerPrimitive.Trigger>) {
|
||||||
|
return <DrawerPrimitive.Trigger data-slot="drawer-trigger" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function DrawerPortal({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DrawerPrimitive.Portal>) {
|
||||||
|
return <DrawerPrimitive.Portal data-slot="drawer-portal" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function DrawerClose({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DrawerPrimitive.Close>) {
|
||||||
|
return <DrawerPrimitive.Close data-slot="drawer-close" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function DrawerOverlay({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DrawerPrimitive.Overlay>) {
|
||||||
|
return (
|
||||||
|
<DrawerPrimitive.Overlay
|
||||||
|
data-slot="drawer-overlay"
|
||||||
|
className={cn(
|
||||||
|
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DrawerContent({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DrawerPrimitive.Content>) {
|
||||||
|
return (
|
||||||
|
<DrawerPortal data-slot="drawer-portal">
|
||||||
|
<DrawerOverlay />
|
||||||
|
<DrawerPrimitive.Content
|
||||||
|
data-slot="drawer-content"
|
||||||
|
className={cn(
|
||||||
|
"group/drawer-content bg-background fixed z-50 flex h-auto flex-col",
|
||||||
|
"data-[vaul-drawer-direction=top]:inset-x-0 data-[vaul-drawer-direction=top]:top-0 data-[vaul-drawer-direction=top]:mb-24 data-[vaul-drawer-direction=top]:max-h-[80vh] data-[vaul-drawer-direction=top]:rounded-b-lg data-[vaul-drawer-direction=top]:border-b",
|
||||||
|
"data-[vaul-drawer-direction=bottom]:inset-x-0 data-[vaul-drawer-direction=bottom]:bottom-0 data-[vaul-drawer-direction=bottom]:mt-24 data-[vaul-drawer-direction=bottom]:max-h-[80vh] data-[vaul-drawer-direction=bottom]:rounded-t-lg data-[vaul-drawer-direction=bottom]:border-t",
|
||||||
|
"data-[vaul-drawer-direction=right]:inset-y-0 data-[vaul-drawer-direction=right]:right-0 data-[vaul-drawer-direction=right]:w-3/4 data-[vaul-drawer-direction=right]:border-l data-[vaul-drawer-direction=right]:sm:max-w-sm",
|
||||||
|
"data-[vaul-drawer-direction=left]:inset-y-0 data-[vaul-drawer-direction=left]:left-0 data-[vaul-drawer-direction=left]:w-3/4 data-[vaul-drawer-direction=left]:border-r data-[vaul-drawer-direction=left]:sm:max-w-sm",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<div className="bg-muted mx-auto mt-4 hidden h-2 w-[100px] shrink-0 rounded-full group-data-[vaul-drawer-direction=bottom]/drawer-content:block" />
|
||||||
|
{children}
|
||||||
|
</DrawerPrimitive.Content>
|
||||||
|
</DrawerPortal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DrawerHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="drawer-header"
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col gap-0.5 p-4 group-data-[vaul-drawer-direction=bottom]/drawer-content:text-center group-data-[vaul-drawer-direction=top]/drawer-content:text-center md:gap-1.5 md:text-left",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DrawerFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="drawer-footer"
|
||||||
|
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DrawerTitle({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DrawerPrimitive.Title>) {
|
||||||
|
return (
|
||||||
|
<DrawerPrimitive.Title
|
||||||
|
data-slot="drawer-title"
|
||||||
|
className={cn("text-foreground font-semibold", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DrawerDescription({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DrawerPrimitive.Description>) {
|
||||||
|
return (
|
||||||
|
<DrawerPrimitive.Description
|
||||||
|
data-slot="drawer-description"
|
||||||
|
className={cn("text-muted-foreground text-sm", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Drawer,
|
||||||
|
DrawerPortal,
|
||||||
|
DrawerOverlay,
|
||||||
|
DrawerTrigger,
|
||||||
|
DrawerClose,
|
||||||
|
DrawerContent,
|
||||||
|
DrawerHeader,
|
||||||
|
DrawerFooter,
|
||||||
|
DrawerTitle,
|
||||||
|
DrawerDescription,
|
||||||
|
};
|
||||||
@ -2,7 +2,7 @@ import * as React from "react";
|
|||||||
|
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
export type InputProps = React.InputHTMLAttributes<HTMLInputElement>
|
export type InputProps = React.InputHTMLAttributes<HTMLInputElement>;
|
||||||
|
|
||||||
const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||||
({ className, type, ...props }, ref) => {
|
({ className, type, ...props }, ref) => {
|
||||||
@ -21,4 +21,4 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
|||||||
);
|
);
|
||||||
Input.displayName = "Input";
|
Input.displayName = "Input";
|
||||||
|
|
||||||
export { Input };
|
export { Input };
|
||||||
|
|||||||
@ -23,4 +23,4 @@ const Label = React.forwardRef<
|
|||||||
));
|
));
|
||||||
Label.displayName = LabelPrimitive.Root.displayName;
|
Label.displayName = LabelPrimitive.Root.displayName;
|
||||||
|
|
||||||
export { Label };
|
export { Label };
|
||||||
|
|||||||
@ -128,7 +128,9 @@ export default function MetricCard({
|
|||||||
getIconClasses()
|
getIconClasses()
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<span className="text-lg transition-transform duration-300 group-hover:scale-110">{icon}</span>
|
<span className="text-lg transition-transform duration-300 group-hover:scale-110">
|
||||||
|
{icon}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
185
components/ui/select.tsx
Normal file
185
components/ui/select.tsx
Normal file
@ -0,0 +1,185 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import * as SelectPrimitive from "@radix-ui/react-select";
|
||||||
|
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
function Select({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
|
||||||
|
return <SelectPrimitive.Root data-slot="select" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectGroup({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
|
||||||
|
return <SelectPrimitive.Group data-slot="select-group" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectValue({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
|
||||||
|
return <SelectPrimitive.Value data-slot="select-value" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectTrigger({
|
||||||
|
className,
|
||||||
|
size = "default",
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
|
||||||
|
size?: "sm" | "default";
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Trigger
|
||||||
|
data-slot="select-trigger"
|
||||||
|
data-size={size}
|
||||||
|
className={cn(
|
||||||
|
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<SelectPrimitive.Icon asChild>
|
||||||
|
<ChevronDownIcon className="size-4 opacity-50" />
|
||||||
|
</SelectPrimitive.Icon>
|
||||||
|
</SelectPrimitive.Trigger>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectContent({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
position = "popper",
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Portal>
|
||||||
|
<SelectPrimitive.Content
|
||||||
|
data-slot="select-content"
|
||||||
|
className={cn(
|
||||||
|
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
|
||||||
|
position === "popper" &&
|
||||||
|
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
position={position}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<SelectScrollUpButton />
|
||||||
|
<SelectPrimitive.Viewport
|
||||||
|
className={cn(
|
||||||
|
"p-1",
|
||||||
|
position === "popper" &&
|
||||||
|
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</SelectPrimitive.Viewport>
|
||||||
|
<SelectScrollDownButton />
|
||||||
|
</SelectPrimitive.Content>
|
||||||
|
</SelectPrimitive.Portal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectLabel({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Label
|
||||||
|
data-slot="select-label"
|
||||||
|
className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectItem({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Item
|
||||||
|
data-slot="select-item"
|
||||||
|
className={cn(
|
||||||
|
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="absolute right-2 flex size-3.5 items-center justify-center">
|
||||||
|
<SelectPrimitive.ItemIndicator>
|
||||||
|
<CheckIcon className="size-4" />
|
||||||
|
</SelectPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||||
|
</SelectPrimitive.Item>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectSeparator({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Separator
|
||||||
|
data-slot="select-separator"
|
||||||
|
className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectScrollUpButton({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.ScrollUpButton
|
||||||
|
data-slot="select-scroll-up-button"
|
||||||
|
className={cn(
|
||||||
|
"flex cursor-default items-center justify-center py-1",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ChevronUpIcon className="size-4" />
|
||||||
|
</SelectPrimitive.ScrollUpButton>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectScrollDownButton({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.ScrollDownButton
|
||||||
|
data-slot="select-scroll-down-button"
|
||||||
|
className={cn(
|
||||||
|
"flex cursor-default items-center justify-center py-1",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ChevronDownIcon className="size-4" />
|
||||||
|
</SelectPrimitive.ScrollDownButton>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectGroup,
|
||||||
|
SelectItem,
|
||||||
|
SelectLabel,
|
||||||
|
SelectScrollDownButton,
|
||||||
|
SelectScrollUpButton,
|
||||||
|
SelectSeparator,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
};
|
||||||
63
components/ui/slider.tsx
Normal file
63
components/ui/slider.tsx
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import * as SliderPrimitive from "@radix-ui/react-slider";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
function Slider({
|
||||||
|
className,
|
||||||
|
defaultValue,
|
||||||
|
value,
|
||||||
|
min = 0,
|
||||||
|
max = 100,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SliderPrimitive.Root>) {
|
||||||
|
const _values = React.useMemo(
|
||||||
|
() =>
|
||||||
|
Array.isArray(value)
|
||||||
|
? value
|
||||||
|
: Array.isArray(defaultValue)
|
||||||
|
? defaultValue
|
||||||
|
: [min, max],
|
||||||
|
[value, defaultValue, min, max]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SliderPrimitive.Root
|
||||||
|
data-slot="slider"
|
||||||
|
defaultValue={defaultValue}
|
||||||
|
value={value}
|
||||||
|
min={min}
|
||||||
|
max={max}
|
||||||
|
className={cn(
|
||||||
|
"relative flex w-full touch-none items-center select-none data-[disabled]:opacity-50 data-[orientation=vertical]:h-full data-[orientation=vertical]:min-h-44 data-[orientation=vertical]:w-auto data-[orientation=vertical]:flex-col",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<SliderPrimitive.Track
|
||||||
|
data-slot="slider-track"
|
||||||
|
className={cn(
|
||||||
|
"bg-muted relative grow overflow-hidden rounded-full data-[orientation=horizontal]:h-1.5 data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-1.5"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<SliderPrimitive.Range
|
||||||
|
data-slot="slider-range"
|
||||||
|
className={cn(
|
||||||
|
"bg-primary absolute data-[orientation=horizontal]:h-full data-[orientation=vertical]:w-full"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</SliderPrimitive.Track>
|
||||||
|
{Array.from({ length: _values.length }, (_, index) => (
|
||||||
|
<SliderPrimitive.Thumb
|
||||||
|
data-slot="slider-thumb"
|
||||||
|
key={index}
|
||||||
|
className="border-primary bg-background ring-ring/50 block size-4 shrink-0 rounded-full border shadow-sm transition-[color,box-shadow] hover:ring-4 focus-visible:ring-4 focus-visible:outline-hidden disabled:pointer-events-none disabled:opacity-50"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</SliderPrimitive.Root>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Slider };
|
||||||
@ -28,4 +28,4 @@ const Toaster = ({ ...props }: ToasterProps) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export { Toaster };
|
export { Toaster };
|
||||||
|
|||||||
31
components/ui/switch.tsx
Normal file
31
components/ui/switch.tsx
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import * as SwitchPrimitive from "@radix-ui/react-switch";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
function Switch({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SwitchPrimitive.Root>) {
|
||||||
|
return (
|
||||||
|
<SwitchPrimitive.Root
|
||||||
|
data-slot="switch"
|
||||||
|
className={cn(
|
||||||
|
"peer data-[state=checked]:bg-primary data-[state=unchecked]:bg-input focus-visible:border-ring focus-visible:ring-ring/50 dark:data-[state=unchecked]:bg-input/80 inline-flex h-[1.15rem] w-8 shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<SwitchPrimitive.Thumb
|
||||||
|
data-slot="switch-thumb"
|
||||||
|
className={cn(
|
||||||
|
"bg-background dark:data-[state=unchecked]:bg-foreground dark:data-[state=checked]:bg-primary-foreground pointer-events-none block size-4 rounded-full ring-0 transition-transform data-[state=checked]:translate-x-[calc(100%-2px)] data-[state=unchecked]:translate-x-0"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</SwitchPrimitive.Root>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Switch };
|
||||||
116
components/ui/table.tsx
Normal file
116
components/ui/table.tsx
Normal file
@ -0,0 +1,116 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
function Table({ className, ...props }: React.ComponentProps<"table">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="table-container"
|
||||||
|
className="relative w-full overflow-x-auto"
|
||||||
|
>
|
||||||
|
<table
|
||||||
|
data-slot="table"
|
||||||
|
className={cn("w-full caption-bottom text-sm", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
|
||||||
|
return (
|
||||||
|
<thead
|
||||||
|
data-slot="table-header"
|
||||||
|
className={cn("[&_tr]:border-b", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
|
||||||
|
return (
|
||||||
|
<tbody
|
||||||
|
data-slot="table-body"
|
||||||
|
className={cn("[&_tr:last-child]:border-0", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
|
||||||
|
return (
|
||||||
|
<tfoot
|
||||||
|
data-slot="table-footer"
|
||||||
|
className={cn(
|
||||||
|
"bg-muted/50 border-t font-medium [&>tr]:last:border-b-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
|
||||||
|
return (
|
||||||
|
<tr
|
||||||
|
data-slot="table-row"
|
||||||
|
className={cn(
|
||||||
|
"hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
|
||||||
|
return (
|
||||||
|
<th
|
||||||
|
data-slot="table-head"
|
||||||
|
className={cn(
|
||||||
|
"text-foreground h-10 px-2 text-left align-middle font-medium whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
|
||||||
|
return (
|
||||||
|
<td
|
||||||
|
data-slot="table-cell"
|
||||||
|
className={cn(
|
||||||
|
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableCaption({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"caption">) {
|
||||||
|
return (
|
||||||
|
<caption
|
||||||
|
data-slot="table-caption"
|
||||||
|
className={cn("text-muted-foreground mt-4 text-sm", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Table,
|
||||||
|
TableHeader,
|
||||||
|
TableBody,
|
||||||
|
TableFooter,
|
||||||
|
TableHead,
|
||||||
|
TableRow,
|
||||||
|
TableCell,
|
||||||
|
TableCaption,
|
||||||
|
};
|
||||||
66
components/ui/tabs.tsx
Normal file
66
components/ui/tabs.tsx
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import * as TabsPrimitive from "@radix-ui/react-tabs";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
function Tabs({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
|
||||||
|
return (
|
||||||
|
<TabsPrimitive.Root
|
||||||
|
data-slot="tabs"
|
||||||
|
className={cn("flex flex-col gap-2", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TabsList({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof TabsPrimitive.List>) {
|
||||||
|
return (
|
||||||
|
<TabsPrimitive.List
|
||||||
|
data-slot="tabs-list"
|
||||||
|
className={cn(
|
||||||
|
"bg-muted text-muted-foreground inline-flex h-9 w-fit items-center justify-center rounded-lg p-[3px]",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TabsTrigger({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
|
||||||
|
return (
|
||||||
|
<TabsPrimitive.Trigger
|
||||||
|
data-slot="tabs-trigger"
|
||||||
|
className={cn(
|
||||||
|
"data-[state=active]:bg-background dark:data-[state=active]:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 text-foreground dark:text-muted-foreground inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow-sm [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TabsContent({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
|
||||||
|
return (
|
||||||
|
<TabsPrimitive.Content
|
||||||
|
data-slot="tabs-content"
|
||||||
|
className={cn("flex-1 outline-none", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Tabs, TabsList, TabsTrigger, TabsContent };
|
||||||
@ -68,4 +68,4 @@ export function SimpleThemeToggle() {
|
|||||||
<span className="sr-only">Toggle theme</span>
|
<span className="sr-only">Toggle theme</span>
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
73
components/ui/toggle-group.tsx
Normal file
73
components/ui/toggle-group.tsx
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import * as ToggleGroupPrimitive from "@radix-ui/react-toggle-group";
|
||||||
|
import { type VariantProps } from "class-variance-authority";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { toggleVariants } from "@/components/ui/toggle";
|
||||||
|
|
||||||
|
const ToggleGroupContext = React.createContext<
|
||||||
|
VariantProps<typeof toggleVariants>
|
||||||
|
>({
|
||||||
|
size: "default",
|
||||||
|
variant: "default",
|
||||||
|
});
|
||||||
|
|
||||||
|
function ToggleGroup({
|
||||||
|
className,
|
||||||
|
variant,
|
||||||
|
size,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ToggleGroupPrimitive.Root> &
|
||||||
|
VariantProps<typeof toggleVariants>) {
|
||||||
|
return (
|
||||||
|
<ToggleGroupPrimitive.Root
|
||||||
|
data-slot="toggle-group"
|
||||||
|
data-variant={variant}
|
||||||
|
data-size={size}
|
||||||
|
className={cn(
|
||||||
|
"group/toggle-group flex w-fit items-center rounded-md data-[variant=outline]:shadow-xs",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ToggleGroupContext.Provider value={{ variant, size }}>
|
||||||
|
{children}
|
||||||
|
</ToggleGroupContext.Provider>
|
||||||
|
</ToggleGroupPrimitive.Root>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ToggleGroupItem({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
variant,
|
||||||
|
size,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ToggleGroupPrimitive.Item> &
|
||||||
|
VariantProps<typeof toggleVariants>) {
|
||||||
|
const context = React.useContext(ToggleGroupContext);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ToggleGroupPrimitive.Item
|
||||||
|
data-slot="toggle-group-item"
|
||||||
|
data-variant={context.variant || variant}
|
||||||
|
data-size={context.size || size}
|
||||||
|
className={cn(
|
||||||
|
toggleVariants({
|
||||||
|
variant: context.variant || variant,
|
||||||
|
size: context.size || size,
|
||||||
|
}),
|
||||||
|
"min-w-0 flex-1 shrink-0 rounded-none shadow-none first:rounded-l-md last:rounded-r-md focus:z-10 focus-visible:z-10 data-[variant=outline]:border-l-0 data-[variant=outline]:first:border-l",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</ToggleGroupPrimitive.Item>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { ToggleGroup, ToggleGroupItem };
|
||||||
47
components/ui/toggle.tsx
Normal file
47
components/ui/toggle.tsx
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import * as TogglePrimitive from "@radix-ui/react-toggle";
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const toggleVariants = cva(
|
||||||
|
"inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium hover:bg-muted hover:text-muted-foreground disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0 focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] outline-none transition-[color,box-shadow] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive whitespace-nowrap",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "bg-transparent",
|
||||||
|
outline:
|
||||||
|
"border border-input bg-transparent shadow-xs hover:bg-accent hover:text-accent-foreground",
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
default: "h-9 px-2 min-w-9",
|
||||||
|
sm: "h-8 px-1.5 min-w-8",
|
||||||
|
lg: "h-10 px-2.5 min-w-10",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
size: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
function Toggle({
|
||||||
|
className,
|
||||||
|
variant,
|
||||||
|
size,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof TogglePrimitive.Root> &
|
||||||
|
VariantProps<typeof toggleVariants>) {
|
||||||
|
return (
|
||||||
|
<TogglePrimitive.Root
|
||||||
|
data-slot="toggle"
|
||||||
|
className={cn(toggleVariants({ variant, size, className }))}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Toggle, toggleVariants };
|
||||||
@ -1,18 +1,20 @@
|
|||||||
import { test, expect } from '@playwright/test';
|
import { test, expect } from "@playwright/test";
|
||||||
|
|
||||||
test('has title', async ({ page }) => {
|
test("has title", async ({ page }) => {
|
||||||
await page.goto('https://playwright.dev/');
|
await page.goto("https://playwright.dev/");
|
||||||
|
|
||||||
// Expect a title "to contain" a substring.
|
// Expect a title "to contain" a substring.
|
||||||
await expect(page).toHaveTitle(/Playwright/);
|
await expect(page).toHaveTitle(/Playwright/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('get started link', async ({ page }) => {
|
test("get started link", async ({ page }) => {
|
||||||
await page.goto('https://playwright.dev/');
|
await page.goto("https://playwright.dev/");
|
||||||
|
|
||||||
// Click the get started link.
|
// Click the get started link.
|
||||||
await page.getByRole('link', { name: 'Get started' }).click();
|
await page.getByRole("link", { name: "Get started" }).click();
|
||||||
|
|
||||||
// Expects page to have a heading with the name of Installation.
|
// Expects page to have a heading with the name of Installation.
|
||||||
await expect(page.getByRole('heading', { name: 'Installation' })).toBeVisible();
|
await expect(
|
||||||
|
page.getByRole("heading", { name: "Installation" })
|
||||||
|
).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|||||||
@ -5,26 +5,26 @@
|
|||||||
// Custom mappings for specific enum values that need special formatting
|
// Custom mappings for specific enum values that need special formatting
|
||||||
const ENUM_MAPPINGS: Record<string, string> = {
|
const ENUM_MAPPINGS: Record<string, string> = {
|
||||||
// HR/Employment related
|
// HR/Employment related
|
||||||
'SALARY_COMPENSATION': 'Salary & Compensation',
|
SALARY_COMPENSATION: "Salary & Compensation",
|
||||||
'CONTRACT_HOURS': 'Contract & Hours',
|
CONTRACT_HOURS: "Contract & Hours",
|
||||||
'SCHEDULE_HOURS': 'Schedule & Hours',
|
SCHEDULE_HOURS: "Schedule & Hours",
|
||||||
'LEAVE_VACATION': 'Leave & Vacation',
|
LEAVE_VACATION: "Leave & Vacation",
|
||||||
'SICK_LEAVE_RECOVERY': 'Sick Leave & Recovery',
|
SICK_LEAVE_RECOVERY: "Sick Leave & Recovery",
|
||||||
'WORKWEAR_STAFF_PASS': 'Workwear & Staff Pass',
|
WORKWEAR_STAFF_PASS: "Workwear & Staff Pass",
|
||||||
'TEAM_CONTACTS': 'Team & Contacts',
|
TEAM_CONTACTS: "Team & Contacts",
|
||||||
'PERSONAL_QUESTIONS': 'Personal Questions',
|
PERSONAL_QUESTIONS: "Personal Questions",
|
||||||
'PERSONALQUESTIONS': 'Personal Questions',
|
PERSONALQUESTIONS: "Personal Questions",
|
||||||
|
|
||||||
// Process related
|
// Process related
|
||||||
'ONBOARDING': 'Onboarding',
|
ONBOARDING: "Onboarding",
|
||||||
'OFFBOARDING': 'Offboarding',
|
OFFBOARDING: "Offboarding",
|
||||||
|
|
||||||
// Access related
|
// Access related
|
||||||
'ACCESS_LOGIN': 'Access & Login',
|
ACCESS_LOGIN: "Access & Login",
|
||||||
|
|
||||||
// Technical/Other
|
// Technical/Other
|
||||||
'UNRECOGNIZED_OTHER': 'General Inquiry',
|
UNRECOGNIZED_OTHER: "General Inquiry",
|
||||||
|
|
||||||
// Add more mappings as needed
|
// Add more mappings as needed
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -33,19 +33,21 @@ const ENUM_MAPPINGS: Record<string, string> = {
|
|||||||
* @param enumValue - The raw enum value from the database
|
* @param enumValue - The raw enum value from the database
|
||||||
* @returns Formatted string or null if input is empty
|
* @returns Formatted string or null if input is empty
|
||||||
*/
|
*/
|
||||||
export function formatEnumValue(enumValue: string | null | undefined): string | null {
|
export function formatEnumValue(
|
||||||
|
enumValue: string | null | undefined
|
||||||
|
): string | null {
|
||||||
if (!enumValue) return null;
|
if (!enumValue) return null;
|
||||||
|
|
||||||
// Check for custom mapping first
|
// Check for custom mapping first
|
||||||
if (ENUM_MAPPINGS[enumValue]) {
|
if (ENUM_MAPPINGS[enumValue]) {
|
||||||
return ENUM_MAPPINGS[enumValue];
|
return ENUM_MAPPINGS[enumValue];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback: convert snake_case to Title Case
|
// Fallback: convert snake_case to Title Case
|
||||||
return enumValue
|
return enumValue
|
||||||
.replace(/_/g, ' ')
|
.replace(/_/g, " ")
|
||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
.replace(/\b\w/g, l => l.toUpperCase());
|
.replace(/\b\w/g, (l) => l.toUpperCase());
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -53,7 +55,9 @@ export function formatEnumValue(enumValue: string | null | undefined): string |
|
|||||||
* @param category - The category enum value
|
* @param category - The category enum value
|
||||||
* @returns Formatted category name or null if empty
|
* @returns Formatted category name or null if empty
|
||||||
*/
|
*/
|
||||||
export function formatCategory(category: string | null | undefined): string | null {
|
export function formatCategory(
|
||||||
|
category: string | null | undefined
|
||||||
|
): string | null {
|
||||||
return formatEnumValue(category);
|
return formatEnumValue(category);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -62,8 +66,10 @@ export function formatCategory(category: string | null | undefined): string | nu
|
|||||||
* @param enumValues - Array of enum values
|
* @param enumValues - Array of enum values
|
||||||
* @returns Array of formatted values (filters out null/undefined)
|
* @returns Array of formatted values (filters out null/undefined)
|
||||||
*/
|
*/
|
||||||
export function formatEnumArray(enumValues: (string | null | undefined)[]): string[] {
|
export function formatEnumArray(
|
||||||
|
enumValues: (string | null | undefined)[]
|
||||||
|
): string[] {
|
||||||
return enumValues
|
return enumValues
|
||||||
.map(value => formatEnumValue(value))
|
.map((value) => formatEnumValue(value))
|
||||||
.filter((value): value is string => Boolean(value));
|
.filter((value): value is string => Boolean(value));
|
||||||
}
|
}
|
||||||
|
|||||||
17
package.json
17
package.json
@ -29,12 +29,23 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@prisma/adapter-pg": "^6.10.1",
|
"@prisma/adapter-pg": "^6.10.1",
|
||||||
"@prisma/client": "^6.10.1",
|
"@prisma/client": "^6.10.1",
|
||||||
|
"@radix-ui/react-accordion": "^1.2.11",
|
||||||
|
"@radix-ui/react-alert-dialog": "^1.1.14",
|
||||||
|
"@radix-ui/react-collapsible": "^1.1.11",
|
||||||
|
"@radix-ui/react-dialog": "^1.1.14",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.15",
|
"@radix-ui/react-dropdown-menu": "^2.1.15",
|
||||||
"@radix-ui/react-label": "^2.1.7",
|
"@radix-ui/react-label": "^2.1.7",
|
||||||
|
"@radix-ui/react-select": "^2.2.5",
|
||||||
"@radix-ui/react-separator": "^1.1.7",
|
"@radix-ui/react-separator": "^1.1.7",
|
||||||
|
"@radix-ui/react-slider": "^1.3.5",
|
||||||
"@radix-ui/react-slot": "^1.2.3",
|
"@radix-ui/react-slot": "^1.2.3",
|
||||||
|
"@radix-ui/react-switch": "^1.2.5",
|
||||||
|
"@radix-ui/react-tabs": "^1.1.12",
|
||||||
|
"@radix-ui/react-toggle": "^1.1.9",
|
||||||
|
"@radix-ui/react-toggle-group": "^1.1.10",
|
||||||
"@radix-ui/react-tooltip": "^1.2.7",
|
"@radix-ui/react-tooltip": "^1.2.7",
|
||||||
"@rapideditor/country-coder": "^5.4.0",
|
"@rapideditor/country-coder": "^5.4.0",
|
||||||
|
"@tanstack/react-table": "^8.21.3",
|
||||||
"@types/canvas-confetti": "^1.9.0",
|
"@types/canvas-confetti": "^1.9.0",
|
||||||
"@types/d3": "^7.4.3",
|
"@types/d3": "^7.4.3",
|
||||||
"@types/d3-cloud": "^1.2.9",
|
"@types/d3-cloud": "^1.2.9",
|
||||||
@ -50,6 +61,7 @@
|
|||||||
"d3": "^7.9.0",
|
"d3": "^7.9.0",
|
||||||
"d3-cloud": "^1.2.7",
|
"d3-cloud": "^1.2.7",
|
||||||
"d3-selection": "^3.0.0",
|
"d3-selection": "^3.0.0",
|
||||||
|
"date-fns": "^4.1.0",
|
||||||
"i18n-iso-countries": "^7.14.0",
|
"i18n-iso-countries": "^7.14.0",
|
||||||
"iso-639-1": "^3.1.5",
|
"iso-639-1": "^3.1.5",
|
||||||
"leaflet": "^1.9.4",
|
"leaflet": "^1.9.4",
|
||||||
@ -61,6 +73,7 @@
|
|||||||
"node-cron": "^4.1.1",
|
"node-cron": "^4.1.1",
|
||||||
"node-fetch": "^3.3.2",
|
"node-fetch": "^3.3.2",
|
||||||
"react": "^19.1.0",
|
"react": "^19.1.0",
|
||||||
|
"react-day-picker": "^9.7.0",
|
||||||
"react-dom": "^19.1.0",
|
"react-dom": "^19.1.0",
|
||||||
"react-leaflet": "^5.0.0",
|
"react-leaflet": "^5.0.0",
|
||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
@ -68,6 +81,7 @@
|
|||||||
"rehype-raw": "^7.0.0",
|
"rehype-raw": "^7.0.0",
|
||||||
"sonner": "^2.0.5",
|
"sonner": "^2.0.5",
|
||||||
"tailwind-merge": "^3.3.1",
|
"tailwind-merge": "^3.3.1",
|
||||||
|
"vaul": "^1.1.2",
|
||||||
"zod": "^3.25.67"
|
"zod": "^3.25.67"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@ -76,6 +90,7 @@
|
|||||||
"@playwright/test": "^1.53.1",
|
"@playwright/test": "^1.53.1",
|
||||||
"@tailwindcss/postcss": "^4.1.11",
|
"@tailwindcss/postcss": "^4.1.11",
|
||||||
"@testing-library/dom": "^10.4.0",
|
"@testing-library/dom": "^10.4.0",
|
||||||
|
"@testing-library/jest-dom": "^6.6.3",
|
||||||
"@testing-library/react": "^16.3.0",
|
"@testing-library/react": "^16.3.0",
|
||||||
"@types/node": "^24.0.6",
|
"@types/node": "^24.0.6",
|
||||||
"@types/node-cron": "^3.0.11",
|
"@types/node-cron": "^3.0.11",
|
||||||
@ -89,8 +104,10 @@
|
|||||||
"eslint": "^9.30.0",
|
"eslint": "^9.30.0",
|
||||||
"eslint-config-next": "^15.3.4",
|
"eslint-config-next": "^15.3.4",
|
||||||
"eslint-plugin-prettier": "^5.5.1",
|
"eslint-plugin-prettier": "^5.5.1",
|
||||||
|
"jest-axe": "^10.0.0",
|
||||||
"jsdom": "^26.1.0",
|
"jsdom": "^26.1.0",
|
||||||
"markdownlint-cli2": "^0.18.1",
|
"markdownlint-cli2": "^0.18.1",
|
||||||
|
"node-mocks-http": "^1.17.2",
|
||||||
"postcss": "^8.5.6",
|
"postcss": "^8.5.6",
|
||||||
"prettier": "^3.6.2",
|
"prettier": "^3.6.2",
|
||||||
"prettier-plugin-jinja-template": "^2.1.0",
|
"prettier-plugin-jinja-template": "^2.1.0",
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { defineConfig, devices } from '@playwright/test';
|
import { defineConfig, devices } from "@playwright/test";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Read environment variables from file.
|
* Read environment variables from file.
|
||||||
@ -12,7 +12,7 @@ import { defineConfig, devices } from '@playwright/test';
|
|||||||
* See https://playwright.dev/docs/test-configuration.
|
* See https://playwright.dev/docs/test-configuration.
|
||||||
*/
|
*/
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
testDir: './e2e',
|
testDir: "./e2e",
|
||||||
/* Run tests in files in parallel */
|
/* Run tests in files in parallel */
|
||||||
fullyParallel: true,
|
fullyParallel: true,
|
||||||
/* Fail the build on CI if you accidentally left test.only in the source code. */
|
/* Fail the build on CI if you accidentally left test.only in the source code. */
|
||||||
@ -22,31 +22,31 @@ export default defineConfig({
|
|||||||
/* Opt out of parallel tests on CI. */
|
/* Opt out of parallel tests on CI. */
|
||||||
workers: process.env.CI ? 1 : undefined,
|
workers: process.env.CI ? 1 : undefined,
|
||||||
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
|
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
|
||||||
reporter: 'html',
|
reporter: "html",
|
||||||
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
|
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
|
||||||
use: {
|
use: {
|
||||||
/* Base URL to use in actions like `await page.goto('/')`. */
|
/* Base URL to use in actions like `await page.goto('/')`. */
|
||||||
// baseURL: 'http://localhost:3000',
|
// baseURL: 'http://localhost:3000',
|
||||||
|
|
||||||
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
|
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
|
||||||
trace: 'on-first-retry',
|
trace: "on-first-retry",
|
||||||
},
|
},
|
||||||
|
|
||||||
/* Configure projects for major browsers */
|
/* Configure projects for major browsers */
|
||||||
projects: [
|
projects: [
|
||||||
{
|
{
|
||||||
name: 'chromium',
|
name: "chromium",
|
||||||
use: { ...devices['Desktop Chrome'] },
|
use: { ...devices["Desktop Chrome"] },
|
||||||
},
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
name: 'firefox',
|
name: "firefox",
|
||||||
use: { ...devices['Desktop Firefox'] },
|
use: { ...devices["Desktop Firefox"] },
|
||||||
},
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
name: 'webkit',
|
name: "webkit",
|
||||||
use: { ...devices['Desktop Safari'] },
|
use: { ...devices["Desktop Safari"] },
|
||||||
},
|
},
|
||||||
|
|
||||||
/* Test against mobile viewports. */
|
/* Test against mobile viewports. */
|
||||||
|
|||||||
701
pnpm-lock.yaml
generated
701
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@ -1,75 +1,77 @@
|
|||||||
import { test, expect, type Page } from '@playwright/test';
|
import { test, expect, type Page } from "@playwright/test";
|
||||||
|
|
||||||
test.beforeEach(async ({ page }) => {
|
test.beforeEach(async ({ page }) => {
|
||||||
await page.goto('https://demo.playwright.dev/todomvc');
|
await page.goto("https://demo.playwright.dev/todomvc");
|
||||||
});
|
});
|
||||||
|
|
||||||
const TODO_ITEMS = [
|
const TODO_ITEMS = [
|
||||||
'buy some cheese',
|
"buy some cheese",
|
||||||
'feed the cat',
|
"feed the cat",
|
||||||
'book a doctors appointment'
|
"book a doctors appointment",
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
test.describe('New Todo', () => {
|
test.describe("New Todo", () => {
|
||||||
test('should allow me to add todo items', async ({ page }) => {
|
test("should allow me to add todo items", async ({ page }) => {
|
||||||
// create a new todo locator
|
// create a new todo locator
|
||||||
const newTodo = page.getByPlaceholder('What needs to be done?');
|
const newTodo = page.getByPlaceholder("What needs to be done?");
|
||||||
|
|
||||||
// Create 1st todo.
|
// Create 1st todo.
|
||||||
await newTodo.fill(TODO_ITEMS[0]);
|
await newTodo.fill(TODO_ITEMS[0]);
|
||||||
await newTodo.press('Enter');
|
await newTodo.press("Enter");
|
||||||
|
|
||||||
// Make sure the list only has one todo item.
|
// Make sure the list only has one todo item.
|
||||||
await expect(page.getByTestId('todo-title')).toHaveText([
|
await expect(page.getByTestId("todo-title")).toHaveText([TODO_ITEMS[0]]);
|
||||||
TODO_ITEMS[0]
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Create 2nd todo.
|
// Create 2nd todo.
|
||||||
await newTodo.fill(TODO_ITEMS[1]);
|
await newTodo.fill(TODO_ITEMS[1]);
|
||||||
await newTodo.press('Enter');
|
await newTodo.press("Enter");
|
||||||
|
|
||||||
// Make sure the list now has two todo items.
|
// Make sure the list now has two todo items.
|
||||||
await expect(page.getByTestId('todo-title')).toHaveText([
|
await expect(page.getByTestId("todo-title")).toHaveText([
|
||||||
TODO_ITEMS[0],
|
TODO_ITEMS[0],
|
||||||
TODO_ITEMS[1]
|
TODO_ITEMS[1],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
await checkNumberOfTodosInLocalStorage(page, 2);
|
await checkNumberOfTodosInLocalStorage(page, 2);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should clear text input field when an item is added', async ({ page }) => {
|
test("should clear text input field when an item is added", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
// create a new todo locator
|
// create a new todo locator
|
||||||
const newTodo = page.getByPlaceholder('What needs to be done?');
|
const newTodo = page.getByPlaceholder("What needs to be done?");
|
||||||
|
|
||||||
// Create one todo item.
|
// Create one todo item.
|
||||||
await newTodo.fill(TODO_ITEMS[0]);
|
await newTodo.fill(TODO_ITEMS[0]);
|
||||||
await newTodo.press('Enter');
|
await newTodo.press("Enter");
|
||||||
|
|
||||||
// Check that input is empty.
|
// Check that input is empty.
|
||||||
await expect(newTodo).toBeEmpty();
|
await expect(newTodo).toBeEmpty();
|
||||||
await checkNumberOfTodosInLocalStorage(page, 1);
|
await checkNumberOfTodosInLocalStorage(page, 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should append new items to the bottom of the list', async ({ page }) => {
|
test("should append new items to the bottom of the list", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
// Create 3 items.
|
// Create 3 items.
|
||||||
await createDefaultTodos(page);
|
await createDefaultTodos(page);
|
||||||
|
|
||||||
// create a todo count locator
|
// create a todo count locator
|
||||||
const todoCount = page.getByTestId('todo-count')
|
const todoCount = page.getByTestId("todo-count");
|
||||||
|
|
||||||
// Check test using different methods.
|
// Check test using different methods.
|
||||||
await expect(page.getByText('3 items left')).toBeVisible();
|
await expect(page.getByText("3 items left")).toBeVisible();
|
||||||
await expect(todoCount).toHaveText('3 items left');
|
await expect(todoCount).toHaveText("3 items left");
|
||||||
await expect(todoCount).toContainText('3');
|
await expect(todoCount).toContainText("3");
|
||||||
await expect(todoCount).toHaveText(/3/);
|
await expect(todoCount).toHaveText(/3/);
|
||||||
|
|
||||||
// Check all items in one call.
|
// Check all items in one call.
|
||||||
await expect(page.getByTestId('todo-title')).toHaveText(TODO_ITEMS);
|
await expect(page.getByTestId("todo-title")).toHaveText(TODO_ITEMS);
|
||||||
await checkNumberOfTodosInLocalStorage(page, 3);
|
await checkNumberOfTodosInLocalStorage(page, 3);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test.describe('Mark all as completed', () => {
|
test.describe("Mark all as completed", () => {
|
||||||
test.beforeEach(async ({ page }) => {
|
test.beforeEach(async ({ page }) => {
|
||||||
await createDefaultTodos(page);
|
await createDefaultTodos(page);
|
||||||
await checkNumberOfTodosInLocalStorage(page, 3);
|
await checkNumberOfTodosInLocalStorage(page, 3);
|
||||||
@ -79,39 +81,47 @@ test.describe('Mark all as completed', () => {
|
|||||||
await checkNumberOfTodosInLocalStorage(page, 3);
|
await checkNumberOfTodosInLocalStorage(page, 3);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should allow me to mark all items as completed', async ({ page }) => {
|
test("should allow me to mark all items as completed", async ({ page }) => {
|
||||||
// Complete all todos.
|
// Complete all todos.
|
||||||
await page.getByLabel('Mark all as complete').check();
|
await page.getByLabel("Mark all as complete").check();
|
||||||
|
|
||||||
// Ensure all todos have 'completed' class.
|
// Ensure all todos have 'completed' class.
|
||||||
await expect(page.getByTestId('todo-item')).toHaveClass(['completed', 'completed', 'completed']);
|
await expect(page.getByTestId("todo-item")).toHaveClass([
|
||||||
|
"completed",
|
||||||
|
"completed",
|
||||||
|
"completed",
|
||||||
|
]);
|
||||||
await checkNumberOfCompletedTodosInLocalStorage(page, 3);
|
await checkNumberOfCompletedTodosInLocalStorage(page, 3);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should allow me to clear the complete state of all items', async ({ page }) => {
|
test("should allow me to clear the complete state of all items", async ({
|
||||||
const toggleAll = page.getByLabel('Mark all as complete');
|
page,
|
||||||
|
}) => {
|
||||||
|
const toggleAll = page.getByLabel("Mark all as complete");
|
||||||
// Check and then immediately uncheck.
|
// Check and then immediately uncheck.
|
||||||
await toggleAll.check();
|
await toggleAll.check();
|
||||||
await toggleAll.uncheck();
|
await toggleAll.uncheck();
|
||||||
|
|
||||||
// Should be no completed classes.
|
// Should be no completed classes.
|
||||||
await expect(page.getByTestId('todo-item')).toHaveClass(['', '', '']);
|
await expect(page.getByTestId("todo-item")).toHaveClass(["", "", ""]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('complete all checkbox should update state when items are completed / cleared', async ({ page }) => {
|
test("complete all checkbox should update state when items are completed / cleared", async ({
|
||||||
const toggleAll = page.getByLabel('Mark all as complete');
|
page,
|
||||||
|
}) => {
|
||||||
|
const toggleAll = page.getByLabel("Mark all as complete");
|
||||||
await toggleAll.check();
|
await toggleAll.check();
|
||||||
await expect(toggleAll).toBeChecked();
|
await expect(toggleAll).toBeChecked();
|
||||||
await checkNumberOfCompletedTodosInLocalStorage(page, 3);
|
await checkNumberOfCompletedTodosInLocalStorage(page, 3);
|
||||||
|
|
||||||
// Uncheck first todo.
|
// Uncheck first todo.
|
||||||
const firstTodo = page.getByTestId('todo-item').nth(0);
|
const firstTodo = page.getByTestId("todo-item").nth(0);
|
||||||
await firstTodo.getByRole('checkbox').uncheck();
|
await firstTodo.getByRole("checkbox").uncheck();
|
||||||
|
|
||||||
// Reuse toggleAll locator and make sure its not checked.
|
// Reuse toggleAll locator and make sure its not checked.
|
||||||
await expect(toggleAll).not.toBeChecked();
|
await expect(toggleAll).not.toBeChecked();
|
||||||
|
|
||||||
await firstTodo.getByRole('checkbox').check();
|
await firstTodo.getByRole("checkbox").check();
|
||||||
await checkNumberOfCompletedTodosInLocalStorage(page, 3);
|
await checkNumberOfCompletedTodosInLocalStorage(page, 3);
|
||||||
|
|
||||||
// Assert the toggle all is checked again.
|
// Assert the toggle all is checked again.
|
||||||
@ -119,205 +129,236 @@ test.describe('Mark all as completed', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test.describe('Item', () => {
|
test.describe("Item", () => {
|
||||||
|
test("should allow me to mark items as complete", async ({ page }) => {
|
||||||
test('should allow me to mark items as complete', async ({ page }) => {
|
|
||||||
// create a new todo locator
|
// create a new todo locator
|
||||||
const newTodo = page.getByPlaceholder('What needs to be done?');
|
const newTodo = page.getByPlaceholder("What needs to be done?");
|
||||||
|
|
||||||
// Create two items.
|
// Create two items.
|
||||||
for (const item of TODO_ITEMS.slice(0, 2)) {
|
for (const item of TODO_ITEMS.slice(0, 2)) {
|
||||||
await newTodo.fill(item);
|
await newTodo.fill(item);
|
||||||
await newTodo.press('Enter');
|
await newTodo.press("Enter");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check first item.
|
// Check first item.
|
||||||
const firstTodo = page.getByTestId('todo-item').nth(0);
|
const firstTodo = page.getByTestId("todo-item").nth(0);
|
||||||
await firstTodo.getByRole('checkbox').check();
|
await firstTodo.getByRole("checkbox").check();
|
||||||
await expect(firstTodo).toHaveClass('completed');
|
await expect(firstTodo).toHaveClass("completed");
|
||||||
|
|
||||||
// Check second item.
|
// Check second item.
|
||||||
const secondTodo = page.getByTestId('todo-item').nth(1);
|
const secondTodo = page.getByTestId("todo-item").nth(1);
|
||||||
await expect(secondTodo).not.toHaveClass('completed');
|
await expect(secondTodo).not.toHaveClass("completed");
|
||||||
await secondTodo.getByRole('checkbox').check();
|
await secondTodo.getByRole("checkbox").check();
|
||||||
|
|
||||||
// Assert completed class.
|
// Assert completed class.
|
||||||
await expect(firstTodo).toHaveClass('completed');
|
await expect(firstTodo).toHaveClass("completed");
|
||||||
await expect(secondTodo).toHaveClass('completed');
|
await expect(secondTodo).toHaveClass("completed");
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should allow me to un-mark items as complete', async ({ page }) => {
|
test("should allow me to un-mark items as complete", async ({ page }) => {
|
||||||
// create a new todo locator
|
// create a new todo locator
|
||||||
const newTodo = page.getByPlaceholder('What needs to be done?');
|
const newTodo = page.getByPlaceholder("What needs to be done?");
|
||||||
|
|
||||||
// Create two items.
|
// Create two items.
|
||||||
for (const item of TODO_ITEMS.slice(0, 2)) {
|
for (const item of TODO_ITEMS.slice(0, 2)) {
|
||||||
await newTodo.fill(item);
|
await newTodo.fill(item);
|
||||||
await newTodo.press('Enter');
|
await newTodo.press("Enter");
|
||||||
}
|
}
|
||||||
|
|
||||||
const firstTodo = page.getByTestId('todo-item').nth(0);
|
const firstTodo = page.getByTestId("todo-item").nth(0);
|
||||||
const secondTodo = page.getByTestId('todo-item').nth(1);
|
const secondTodo = page.getByTestId("todo-item").nth(1);
|
||||||
const firstTodoCheckbox = firstTodo.getByRole('checkbox');
|
const firstTodoCheckbox = firstTodo.getByRole("checkbox");
|
||||||
|
|
||||||
await firstTodoCheckbox.check();
|
await firstTodoCheckbox.check();
|
||||||
await expect(firstTodo).toHaveClass('completed');
|
await expect(firstTodo).toHaveClass("completed");
|
||||||
await expect(secondTodo).not.toHaveClass('completed');
|
await expect(secondTodo).not.toHaveClass("completed");
|
||||||
await checkNumberOfCompletedTodosInLocalStorage(page, 1);
|
await checkNumberOfCompletedTodosInLocalStorage(page, 1);
|
||||||
|
|
||||||
await firstTodoCheckbox.uncheck();
|
await firstTodoCheckbox.uncheck();
|
||||||
await expect(firstTodo).not.toHaveClass('completed');
|
await expect(firstTodo).not.toHaveClass("completed");
|
||||||
await expect(secondTodo).not.toHaveClass('completed');
|
await expect(secondTodo).not.toHaveClass("completed");
|
||||||
await checkNumberOfCompletedTodosInLocalStorage(page, 0);
|
await checkNumberOfCompletedTodosInLocalStorage(page, 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should allow me to edit an item', async ({ page }) => {
|
test("should allow me to edit an item", async ({ page }) => {
|
||||||
await createDefaultTodos(page);
|
await createDefaultTodos(page);
|
||||||
|
|
||||||
const todoItems = page.getByTestId('todo-item');
|
const todoItems = page.getByTestId("todo-item");
|
||||||
const secondTodo = todoItems.nth(1);
|
const secondTodo = todoItems.nth(1);
|
||||||
await secondTodo.dblclick();
|
await secondTodo.dblclick();
|
||||||
await expect(secondTodo.getByRole('textbox', { name: 'Edit' })).toHaveValue(TODO_ITEMS[1]);
|
await expect(secondTodo.getByRole("textbox", { name: "Edit" })).toHaveValue(
|
||||||
await secondTodo.getByRole('textbox', { name: 'Edit' }).fill('buy some sausages');
|
TODO_ITEMS[1]
|
||||||
await secondTodo.getByRole('textbox', { name: 'Edit' }).press('Enter');
|
);
|
||||||
|
await secondTodo
|
||||||
|
.getByRole("textbox", { name: "Edit" })
|
||||||
|
.fill("buy some sausages");
|
||||||
|
await secondTodo.getByRole("textbox", { name: "Edit" }).press("Enter");
|
||||||
|
|
||||||
// Explicitly assert the new text value.
|
// Explicitly assert the new text value.
|
||||||
await expect(todoItems).toHaveText([
|
await expect(todoItems).toHaveText([
|
||||||
TODO_ITEMS[0],
|
TODO_ITEMS[0],
|
||||||
'buy some sausages',
|
"buy some sausages",
|
||||||
TODO_ITEMS[2]
|
TODO_ITEMS[2],
|
||||||
]);
|
]);
|
||||||
await checkTodosInLocalStorage(page, 'buy some sausages');
|
await checkTodosInLocalStorage(page, "buy some sausages");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test.describe('Editing', () => {
|
test.describe("Editing", () => {
|
||||||
test.beforeEach(async ({ page }) => {
|
test.beforeEach(async ({ page }) => {
|
||||||
await createDefaultTodos(page);
|
await createDefaultTodos(page);
|
||||||
await checkNumberOfTodosInLocalStorage(page, 3);
|
await checkNumberOfTodosInLocalStorage(page, 3);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should hide other controls when editing', async ({ page }) => {
|
test("should hide other controls when editing", async ({ page }) => {
|
||||||
const todoItem = page.getByTestId('todo-item').nth(1);
|
const todoItem = page.getByTestId("todo-item").nth(1);
|
||||||
await todoItem.dblclick();
|
await todoItem.dblclick();
|
||||||
await expect(todoItem.getByRole('checkbox')).not.toBeVisible();
|
await expect(todoItem.getByRole("checkbox")).not.toBeVisible();
|
||||||
await expect(todoItem.locator('label', {
|
await expect(
|
||||||
hasText: TODO_ITEMS[1],
|
todoItem.locator("label", {
|
||||||
})).not.toBeVisible();
|
hasText: TODO_ITEMS[1],
|
||||||
|
})
|
||||||
|
).not.toBeVisible();
|
||||||
await checkNumberOfTodosInLocalStorage(page, 3);
|
await checkNumberOfTodosInLocalStorage(page, 3);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should save edits on blur', async ({ page }) => {
|
test("should save edits on blur", async ({ page }) => {
|
||||||
const todoItems = page.getByTestId('todo-item');
|
const todoItems = page.getByTestId("todo-item");
|
||||||
await todoItems.nth(1).dblclick();
|
await todoItems.nth(1).dblclick();
|
||||||
await todoItems.nth(1).getByRole('textbox', { name: 'Edit' }).fill('buy some sausages');
|
await todoItems
|
||||||
await todoItems.nth(1).getByRole('textbox', { name: 'Edit' }).dispatchEvent('blur');
|
.nth(1)
|
||||||
|
.getByRole("textbox", { name: "Edit" })
|
||||||
|
.fill("buy some sausages");
|
||||||
|
await todoItems
|
||||||
|
.nth(1)
|
||||||
|
.getByRole("textbox", { name: "Edit" })
|
||||||
|
.dispatchEvent("blur");
|
||||||
|
|
||||||
await expect(todoItems).toHaveText([
|
await expect(todoItems).toHaveText([
|
||||||
TODO_ITEMS[0],
|
TODO_ITEMS[0],
|
||||||
'buy some sausages',
|
"buy some sausages",
|
||||||
TODO_ITEMS[2],
|
TODO_ITEMS[2],
|
||||||
]);
|
]);
|
||||||
await checkTodosInLocalStorage(page, 'buy some sausages');
|
await checkTodosInLocalStorage(page, "buy some sausages");
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should trim entered text', async ({ page }) => {
|
test("should trim entered text", async ({ page }) => {
|
||||||
const todoItems = page.getByTestId('todo-item');
|
const todoItems = page.getByTestId("todo-item");
|
||||||
await todoItems.nth(1).dblclick();
|
await todoItems.nth(1).dblclick();
|
||||||
await todoItems.nth(1).getByRole('textbox', { name: 'Edit' }).fill(' buy some sausages ');
|
await todoItems
|
||||||
await todoItems.nth(1).getByRole('textbox', { name: 'Edit' }).press('Enter');
|
.nth(1)
|
||||||
|
.getByRole("textbox", { name: "Edit" })
|
||||||
|
.fill(" buy some sausages ");
|
||||||
|
await todoItems
|
||||||
|
.nth(1)
|
||||||
|
.getByRole("textbox", { name: "Edit" })
|
||||||
|
.press("Enter");
|
||||||
|
|
||||||
await expect(todoItems).toHaveText([
|
await expect(todoItems).toHaveText([
|
||||||
TODO_ITEMS[0],
|
TODO_ITEMS[0],
|
||||||
'buy some sausages',
|
"buy some sausages",
|
||||||
TODO_ITEMS[2],
|
TODO_ITEMS[2],
|
||||||
]);
|
]);
|
||||||
await checkTodosInLocalStorage(page, 'buy some sausages');
|
await checkTodosInLocalStorage(page, "buy some sausages");
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should remove the item if an empty text string was entered', async ({ page }) => {
|
test("should remove the item if an empty text string was entered", async ({
|
||||||
const todoItems = page.getByTestId('todo-item');
|
page,
|
||||||
|
}) => {
|
||||||
|
const todoItems = page.getByTestId("todo-item");
|
||||||
await todoItems.nth(1).dblclick();
|
await todoItems.nth(1).dblclick();
|
||||||
await todoItems.nth(1).getByRole('textbox', { name: 'Edit' }).fill('');
|
await todoItems.nth(1).getByRole("textbox", { name: "Edit" }).fill("");
|
||||||
await todoItems.nth(1).getByRole('textbox', { name: 'Edit' }).press('Enter');
|
await todoItems
|
||||||
|
.nth(1)
|
||||||
|
.getByRole("textbox", { name: "Edit" })
|
||||||
|
.press("Enter");
|
||||||
|
|
||||||
await expect(todoItems).toHaveText([
|
await expect(todoItems).toHaveText([TODO_ITEMS[0], TODO_ITEMS[2]]);
|
||||||
TODO_ITEMS[0],
|
|
||||||
TODO_ITEMS[2],
|
|
||||||
]);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should cancel edits on escape', async ({ page }) => {
|
test("should cancel edits on escape", async ({ page }) => {
|
||||||
const todoItems = page.getByTestId('todo-item');
|
const todoItems = page.getByTestId("todo-item");
|
||||||
await todoItems.nth(1).dblclick();
|
await todoItems.nth(1).dblclick();
|
||||||
await todoItems.nth(1).getByRole('textbox', { name: 'Edit' }).fill('buy some sausages');
|
await todoItems
|
||||||
await todoItems.nth(1).getByRole('textbox', { name: 'Edit' }).press('Escape');
|
.nth(1)
|
||||||
|
.getByRole("textbox", { name: "Edit" })
|
||||||
|
.fill("buy some sausages");
|
||||||
|
await todoItems
|
||||||
|
.nth(1)
|
||||||
|
.getByRole("textbox", { name: "Edit" })
|
||||||
|
.press("Escape");
|
||||||
await expect(todoItems).toHaveText(TODO_ITEMS);
|
await expect(todoItems).toHaveText(TODO_ITEMS);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test.describe('Counter', () => {
|
test.describe("Counter", () => {
|
||||||
test('should display the current number of todo items', async ({ page }) => {
|
test("should display the current number of todo items", async ({ page }) => {
|
||||||
// create a new todo locator
|
// create a new todo locator
|
||||||
const newTodo = page.getByPlaceholder('What needs to be done?');
|
const newTodo = page.getByPlaceholder("What needs to be done?");
|
||||||
|
|
||||||
// create a todo count locator
|
// create a todo count locator
|
||||||
const todoCount = page.getByTestId('todo-count')
|
const todoCount = page.getByTestId("todo-count");
|
||||||
|
|
||||||
await newTodo.fill(TODO_ITEMS[0]);
|
await newTodo.fill(TODO_ITEMS[0]);
|
||||||
await newTodo.press('Enter');
|
await newTodo.press("Enter");
|
||||||
|
|
||||||
await expect(todoCount).toContainText('1');
|
await expect(todoCount).toContainText("1");
|
||||||
|
|
||||||
await newTodo.fill(TODO_ITEMS[1]);
|
await newTodo.fill(TODO_ITEMS[1]);
|
||||||
await newTodo.press('Enter');
|
await newTodo.press("Enter");
|
||||||
await expect(todoCount).toContainText('2');
|
await expect(todoCount).toContainText("2");
|
||||||
|
|
||||||
await checkNumberOfTodosInLocalStorage(page, 2);
|
await checkNumberOfTodosInLocalStorage(page, 2);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test.describe('Clear completed button', () => {
|
test.describe("Clear completed button", () => {
|
||||||
test.beforeEach(async ({ page }) => {
|
test.beforeEach(async ({ page }) => {
|
||||||
await createDefaultTodos(page);
|
await createDefaultTodos(page);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should display the correct text', async ({ page }) => {
|
test("should display the correct text", async ({ page }) => {
|
||||||
await page.locator('.todo-list li .toggle').first().check();
|
await page.locator(".todo-list li .toggle").first().check();
|
||||||
await expect(page.getByRole('button', { name: 'Clear completed' })).toBeVisible();
|
await expect(
|
||||||
|
page.getByRole("button", { name: "Clear completed" })
|
||||||
|
).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should remove completed items when clicked', async ({ page }) => {
|
test("should remove completed items when clicked", async ({ page }) => {
|
||||||
const todoItems = page.getByTestId('todo-item');
|
const todoItems = page.getByTestId("todo-item");
|
||||||
await todoItems.nth(1).getByRole('checkbox').check();
|
await todoItems.nth(1).getByRole("checkbox").check();
|
||||||
await page.getByRole('button', { name: 'Clear completed' }).click();
|
await page.getByRole("button", { name: "Clear completed" }).click();
|
||||||
await expect(todoItems).toHaveCount(2);
|
await expect(todoItems).toHaveCount(2);
|
||||||
await expect(todoItems).toHaveText([TODO_ITEMS[0], TODO_ITEMS[2]]);
|
await expect(todoItems).toHaveText([TODO_ITEMS[0], TODO_ITEMS[2]]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should be hidden when there are no items that are completed', async ({ page }) => {
|
test("should be hidden when there are no items that are completed", async ({
|
||||||
await page.locator('.todo-list li .toggle').first().check();
|
page,
|
||||||
await page.getByRole('button', { name: 'Clear completed' }).click();
|
}) => {
|
||||||
await expect(page.getByRole('button', { name: 'Clear completed' })).toBeHidden();
|
await page.locator(".todo-list li .toggle").first().check();
|
||||||
|
await page.getByRole("button", { name: "Clear completed" }).click();
|
||||||
|
await expect(
|
||||||
|
page.getByRole("button", { name: "Clear completed" })
|
||||||
|
).toBeHidden();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test.describe('Persistence', () => {
|
test.describe("Persistence", () => {
|
||||||
test('should persist its data', async ({ page }) => {
|
test("should persist its data", async ({ page }) => {
|
||||||
// create a new todo locator
|
// create a new todo locator
|
||||||
const newTodo = page.getByPlaceholder('What needs to be done?');
|
const newTodo = page.getByPlaceholder("What needs to be done?");
|
||||||
|
|
||||||
for (const item of TODO_ITEMS.slice(0, 2)) {
|
for (const item of TODO_ITEMS.slice(0, 2)) {
|
||||||
await newTodo.fill(item);
|
await newTodo.fill(item);
|
||||||
await newTodo.press('Enter');
|
await newTodo.press("Enter");
|
||||||
}
|
}
|
||||||
|
|
||||||
const todoItems = page.getByTestId('todo-item');
|
const todoItems = page.getByTestId("todo-item");
|
||||||
const firstTodoCheck = todoItems.nth(0).getByRole('checkbox');
|
const firstTodoCheck = todoItems.nth(0).getByRole("checkbox");
|
||||||
await firstTodoCheck.check();
|
await firstTodoCheck.check();
|
||||||
await expect(todoItems).toHaveText([TODO_ITEMS[0], TODO_ITEMS[1]]);
|
await expect(todoItems).toHaveText([TODO_ITEMS[0], TODO_ITEMS[1]]);
|
||||||
await expect(firstTodoCheck).toBeChecked();
|
await expect(firstTodoCheck).toBeChecked();
|
||||||
await expect(todoItems).toHaveClass(['completed', '']);
|
await expect(todoItems).toHaveClass(["completed", ""]);
|
||||||
|
|
||||||
// Ensure there is 1 completed item.
|
// Ensure there is 1 completed item.
|
||||||
await checkNumberOfCompletedTodosInLocalStorage(page, 1);
|
await checkNumberOfCompletedTodosInLocalStorage(page, 1);
|
||||||
@ -326,11 +367,11 @@ test.describe('Persistence', () => {
|
|||||||
await page.reload();
|
await page.reload();
|
||||||
await expect(todoItems).toHaveText([TODO_ITEMS[0], TODO_ITEMS[1]]);
|
await expect(todoItems).toHaveText([TODO_ITEMS[0], TODO_ITEMS[1]]);
|
||||||
await expect(firstTodoCheck).toBeChecked();
|
await expect(firstTodoCheck).toBeChecked();
|
||||||
await expect(todoItems).toHaveClass(['completed', '']);
|
await expect(todoItems).toHaveClass(["completed", ""]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test.describe('Routing', () => {
|
test.describe("Routing", () => {
|
||||||
test.beforeEach(async ({ page }) => {
|
test.beforeEach(async ({ page }) => {
|
||||||
await createDefaultTodos(page);
|
await createDefaultTodos(page);
|
||||||
// make sure the app had a chance to save updated todos in storage
|
// make sure the app had a chance to save updated todos in storage
|
||||||
@ -339,33 +380,33 @@ test.describe('Routing', () => {
|
|||||||
await checkTodosInLocalStorage(page, TODO_ITEMS[0]);
|
await checkTodosInLocalStorage(page, TODO_ITEMS[0]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should allow me to display active items', async ({ page }) => {
|
test("should allow me to display active items", async ({ page }) => {
|
||||||
const todoItem = page.getByTestId('todo-item');
|
const todoItem = page.getByTestId("todo-item");
|
||||||
await page.getByTestId('todo-item').nth(1).getByRole('checkbox').check();
|
await page.getByTestId("todo-item").nth(1).getByRole("checkbox").check();
|
||||||
|
|
||||||
await checkNumberOfCompletedTodosInLocalStorage(page, 1);
|
await checkNumberOfCompletedTodosInLocalStorage(page, 1);
|
||||||
await page.getByRole('link', { name: 'Active' }).click();
|
await page.getByRole("link", { name: "Active" }).click();
|
||||||
await expect(todoItem).toHaveCount(2);
|
await expect(todoItem).toHaveCount(2);
|
||||||
await expect(todoItem).toHaveText([TODO_ITEMS[0], TODO_ITEMS[2]]);
|
await expect(todoItem).toHaveText([TODO_ITEMS[0], TODO_ITEMS[2]]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should respect the back button', async ({ page }) => {
|
test("should respect the back button", async ({ page }) => {
|
||||||
const todoItem = page.getByTestId('todo-item');
|
const todoItem = page.getByTestId("todo-item");
|
||||||
await page.getByTestId('todo-item').nth(1).getByRole('checkbox').check();
|
await page.getByTestId("todo-item").nth(1).getByRole("checkbox").check();
|
||||||
|
|
||||||
await checkNumberOfCompletedTodosInLocalStorage(page, 1);
|
await checkNumberOfCompletedTodosInLocalStorage(page, 1);
|
||||||
|
|
||||||
await test.step('Showing all items', async () => {
|
await test.step("Showing all items", async () => {
|
||||||
await page.getByRole('link', { name: 'All' }).click();
|
await page.getByRole("link", { name: "All" }).click();
|
||||||
await expect(todoItem).toHaveCount(3);
|
await expect(todoItem).toHaveCount(3);
|
||||||
});
|
});
|
||||||
|
|
||||||
await test.step('Showing active items', async () => {
|
await test.step("Showing active items", async () => {
|
||||||
await page.getByRole('link', { name: 'Active' }).click();
|
await page.getByRole("link", { name: "Active" }).click();
|
||||||
});
|
});
|
||||||
|
|
||||||
await test.step('Showing completed items', async () => {
|
await test.step("Showing completed items", async () => {
|
||||||
await page.getByRole('link', { name: 'Completed' }).click();
|
await page.getByRole("link", { name: "Completed" }).click();
|
||||||
});
|
});
|
||||||
|
|
||||||
await expect(todoItem).toHaveCount(1);
|
await expect(todoItem).toHaveCount(1);
|
||||||
@ -375,63 +416,74 @@ test.describe('Routing', () => {
|
|||||||
await expect(todoItem).toHaveCount(3);
|
await expect(todoItem).toHaveCount(3);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should allow me to display completed items', async ({ page }) => {
|
test("should allow me to display completed items", async ({ page }) => {
|
||||||
await page.getByTestId('todo-item').nth(1).getByRole('checkbox').check();
|
await page.getByTestId("todo-item").nth(1).getByRole("checkbox").check();
|
||||||
await checkNumberOfCompletedTodosInLocalStorage(page, 1);
|
await checkNumberOfCompletedTodosInLocalStorage(page, 1);
|
||||||
await page.getByRole('link', { name: 'Completed' }).click();
|
await page.getByRole("link", { name: "Completed" }).click();
|
||||||
await expect(page.getByTestId('todo-item')).toHaveCount(1);
|
await expect(page.getByTestId("todo-item")).toHaveCount(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should allow me to display all items', async ({ page }) => {
|
test("should allow me to display all items", async ({ page }) => {
|
||||||
await page.getByTestId('todo-item').nth(1).getByRole('checkbox').check();
|
await page.getByTestId("todo-item").nth(1).getByRole("checkbox").check();
|
||||||
await checkNumberOfCompletedTodosInLocalStorage(page, 1);
|
await checkNumberOfCompletedTodosInLocalStorage(page, 1);
|
||||||
await page.getByRole('link', { name: 'Active' }).click();
|
await page.getByRole("link", { name: "Active" }).click();
|
||||||
await page.getByRole('link', { name: 'Completed' }).click();
|
await page.getByRole("link", { name: "Completed" }).click();
|
||||||
await page.getByRole('link', { name: 'All' }).click();
|
await page.getByRole("link", { name: "All" }).click();
|
||||||
await expect(page.getByTestId('todo-item')).toHaveCount(3);
|
await expect(page.getByTestId("todo-item")).toHaveCount(3);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should highlight the currently applied filter', async ({ page }) => {
|
test("should highlight the currently applied filter", async ({ page }) => {
|
||||||
await expect(page.getByRole('link', { name: 'All' })).toHaveClass('selected');
|
await expect(page.getByRole("link", { name: "All" })).toHaveClass(
|
||||||
|
"selected"
|
||||||
|
);
|
||||||
|
|
||||||
//create locators for active and completed links
|
//create locators for active and completed links
|
||||||
const activeLink = page.getByRole('link', { name: 'Active' });
|
const activeLink = page.getByRole("link", { name: "Active" });
|
||||||
const completedLink = page.getByRole('link', { name: 'Completed' });
|
const completedLink = page.getByRole("link", { name: "Completed" });
|
||||||
await activeLink.click();
|
await activeLink.click();
|
||||||
|
|
||||||
// Page change - active items.
|
// Page change - active items.
|
||||||
await expect(activeLink).toHaveClass('selected');
|
await expect(activeLink).toHaveClass("selected");
|
||||||
await completedLink.click();
|
await completedLink.click();
|
||||||
|
|
||||||
// Page change - completed items.
|
// Page change - completed items.
|
||||||
await expect(completedLink).toHaveClass('selected');
|
await expect(completedLink).toHaveClass("selected");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
async function createDefaultTodos(page: Page) {
|
async function createDefaultTodos(page: Page) {
|
||||||
// create a new todo locator
|
// create a new todo locator
|
||||||
const newTodo = page.getByPlaceholder('What needs to be done?');
|
const newTodo = page.getByPlaceholder("What needs to be done?");
|
||||||
|
|
||||||
for (const item of TODO_ITEMS) {
|
for (const item of TODO_ITEMS) {
|
||||||
await newTodo.fill(item);
|
await newTodo.fill(item);
|
||||||
await newTodo.press('Enter');
|
await newTodo.press("Enter");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function checkNumberOfTodosInLocalStorage(page: Page, expected: number) {
|
async function checkNumberOfTodosInLocalStorage(page: Page, expected: number) {
|
||||||
return await page.waitForFunction(e => {
|
return await page.waitForFunction((e) => {
|
||||||
return JSON.parse(localStorage['react-todos']).length === e;
|
return JSON.parse(localStorage["react-todos"]).length === e;
|
||||||
}, expected);
|
}, expected);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function checkNumberOfCompletedTodosInLocalStorage(page: Page, expected: number) {
|
async function checkNumberOfCompletedTodosInLocalStorage(
|
||||||
return await page.waitForFunction(e => {
|
page: Page,
|
||||||
return JSON.parse(localStorage['react-todos']).filter((todo: any) => todo.completed).length === e;
|
expected: number
|
||||||
|
) {
|
||||||
|
return await page.waitForFunction((e) => {
|
||||||
|
return (
|
||||||
|
JSON.parse(localStorage["react-todos"]).filter(
|
||||||
|
(todo: any) => todo.completed
|
||||||
|
).length === e
|
||||||
|
);
|
||||||
}, expected);
|
}, expected);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function checkTodosInLocalStorage(page: Page, title: string) {
|
async function checkTodosInLocalStorage(page: Page, title: string) {
|
||||||
return await page.waitForFunction(t => {
|
return await page.waitForFunction((t) => {
|
||||||
return JSON.parse(localStorage['react-todos']).map((todo: any) => todo.title).includes(t);
|
return JSON.parse(localStorage["react-todos"])
|
||||||
|
.map((todo: any) => todo.title)
|
||||||
|
.includes(t);
|
||||||
}, title);
|
}, title);
|
||||||
}
|
}
|
||||||
|
|||||||
458
tests/integration/user-invitation.test.ts
Normal file
458
tests/integration/user-invitation.test.ts
Normal file
@ -0,0 +1,458 @@
|
|||||||
|
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||||
|
import { createMocks } from "node-mocks-http";
|
||||||
|
import { GET, POST } from "@/app/api/dashboard/users/route";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
|
// Mock the database
|
||||||
|
const mockUser = {
|
||||||
|
id: "admin-user-id",
|
||||||
|
email: "admin@example.com",
|
||||||
|
role: "ADMIN",
|
||||||
|
companyId: "test-company-id",
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockCompany = {
|
||||||
|
id: "test-company-id",
|
||||||
|
name: "Test Company",
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockExistingUsers = [
|
||||||
|
{
|
||||||
|
id: "user-1",
|
||||||
|
email: "existing@example.com",
|
||||||
|
role: "USER",
|
||||||
|
companyId: "test-company-id",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "user-2",
|
||||||
|
email: "admin@example.com",
|
||||||
|
role: "ADMIN",
|
||||||
|
companyId: "test-company-id",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
describe("User Invitation Integration Tests", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
// Mock Prisma methods
|
||||||
|
prisma.user = {
|
||||||
|
findMany: async () => mockExistingUsers,
|
||||||
|
findUnique: async () => mockUser,
|
||||||
|
create: async (data: any) => ({
|
||||||
|
id: "new-user-id",
|
||||||
|
email: data.data.email,
|
||||||
|
role: data.data.role,
|
||||||
|
companyId: data.data.companyId,
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
passwordHash: null,
|
||||||
|
isActive: true,
|
||||||
|
lastLoginAt: null,
|
||||||
|
}),
|
||||||
|
} as any;
|
||||||
|
|
||||||
|
prisma.company = {
|
||||||
|
findUnique: async () => mockCompany,
|
||||||
|
} as any;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
// Clean up any mocks
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("GET /api/dashboard/users", () => {
|
||||||
|
it("should return users for authenticated admin", async () => {
|
||||||
|
const { req, res } = createMocks({
|
||||||
|
method: "GET",
|
||||||
|
headers: {
|
||||||
|
"content-type": "application/json",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mock authentication
|
||||||
|
(req as any).auth = {
|
||||||
|
user: mockUser,
|
||||||
|
};
|
||||||
|
|
||||||
|
await GET(req as any);
|
||||||
|
|
||||||
|
expect(res._getStatusCode()).toBe(200);
|
||||||
|
const data = JSON.parse(res._getData());
|
||||||
|
expect(data.users).toHaveLength(2);
|
||||||
|
expect(data.users[0].email).toBe("existing@example.com");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should deny access for non-admin users", async () => {
|
||||||
|
const { req, res } = createMocks({
|
||||||
|
method: "GET",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mock non-admin user
|
||||||
|
(req as any).auth = {
|
||||||
|
user: { ...mockUser, role: "USER" },
|
||||||
|
};
|
||||||
|
|
||||||
|
await GET(req as any);
|
||||||
|
|
||||||
|
expect(res._getStatusCode()).toBe(403);
|
||||||
|
const data = JSON.parse(res._getData());
|
||||||
|
expect(data.error).toBe("Access denied. Admin role required.");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should deny access for unauthenticated requests", async () => {
|
||||||
|
const { req, res } = createMocks({
|
||||||
|
method: "GET",
|
||||||
|
});
|
||||||
|
|
||||||
|
await GET(req as any);
|
||||||
|
|
||||||
|
expect(res._getStatusCode()).toBe(401);
|
||||||
|
const data = JSON.parse(res._getData());
|
||||||
|
expect(data.error).toBe("Unauthorized");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("POST /api/dashboard/users", () => {
|
||||||
|
it("should successfully invite a new user", async () => {
|
||||||
|
const { req, res } = createMocks({
|
||||||
|
method: "POST",
|
||||||
|
body: {
|
||||||
|
email: "newuser@example.com",
|
||||||
|
role: "USER",
|
||||||
|
},
|
||||||
|
headers: {
|
||||||
|
"content-type": "application/json",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mock authentication
|
||||||
|
(req as any).auth = {
|
||||||
|
user: mockUser,
|
||||||
|
};
|
||||||
|
|
||||||
|
await POST(req as any);
|
||||||
|
|
||||||
|
expect(res._getStatusCode()).toBe(201);
|
||||||
|
const data = JSON.parse(res._getData());
|
||||||
|
expect(data.message).toBe("User invited successfully");
|
||||||
|
expect(data.user.email).toBe("newuser@example.com");
|
||||||
|
expect(data.user.role).toBe("USER");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should prevent duplicate email invitations", async () => {
|
||||||
|
const { req, res } = createMocks({
|
||||||
|
method: "POST",
|
||||||
|
body: {
|
||||||
|
email: "existing@example.com",
|
||||||
|
role: "USER",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mock Prisma to simulate existing user
|
||||||
|
prisma.user.findUnique = async () => mockExistingUsers[0] as any;
|
||||||
|
|
||||||
|
(req as any).auth = {
|
||||||
|
user: mockUser,
|
||||||
|
};
|
||||||
|
|
||||||
|
await POST(req as any);
|
||||||
|
|
||||||
|
expect(res._getStatusCode()).toBe(400);
|
||||||
|
const data = JSON.parse(res._getData());
|
||||||
|
expect(data.error).toBe("User with this email already exists");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should validate email format", async () => {
|
||||||
|
const { req, res } = createMocks({
|
||||||
|
method: "POST",
|
||||||
|
body: {
|
||||||
|
email: "invalid-email",
|
||||||
|
role: "USER",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
(req as any).auth = {
|
||||||
|
user: mockUser,
|
||||||
|
};
|
||||||
|
|
||||||
|
await POST(req as any);
|
||||||
|
|
||||||
|
expect(res._getStatusCode()).toBe(400);
|
||||||
|
const data = JSON.parse(res._getData());
|
||||||
|
expect(data.error).toContain("Invalid email format");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should validate role values", async () => {
|
||||||
|
const { req, res } = createMocks({
|
||||||
|
method: "POST",
|
||||||
|
body: {
|
||||||
|
email: "test@example.com",
|
||||||
|
role: "INVALID_ROLE",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
(req as any).auth = {
|
||||||
|
user: mockUser,
|
||||||
|
};
|
||||||
|
|
||||||
|
await POST(req as any);
|
||||||
|
|
||||||
|
expect(res._getStatusCode()).toBe(400);
|
||||||
|
const data = JSON.parse(res._getData());
|
||||||
|
expect(data.error).toContain("Invalid role");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should require email field", async () => {
|
||||||
|
const { req, res } = createMocks({
|
||||||
|
method: "POST",
|
||||||
|
body: {
|
||||||
|
role: "USER",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
(req as any).auth = {
|
||||||
|
user: mockUser,
|
||||||
|
};
|
||||||
|
|
||||||
|
await POST(req as any);
|
||||||
|
|
||||||
|
expect(res._getStatusCode()).toBe(400);
|
||||||
|
const data = JSON.parse(res._getData());
|
||||||
|
expect(data.error).toContain("Email is required");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should require role field", async () => {
|
||||||
|
const { req, res } = createMocks({
|
||||||
|
method: "POST",
|
||||||
|
body: {
|
||||||
|
email: "test@example.com",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
(req as any).auth = {
|
||||||
|
user: mockUser,
|
||||||
|
};
|
||||||
|
|
||||||
|
await POST(req as any);
|
||||||
|
|
||||||
|
expect(res._getStatusCode()).toBe(400);
|
||||||
|
const data = JSON.parse(res._getData());
|
||||||
|
expect(data.error).toContain("Role is required");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should deny access for non-admin users", async () => {
|
||||||
|
const { req, res } = createMocks({
|
||||||
|
method: "POST",
|
||||||
|
body: {
|
||||||
|
email: "test@example.com",
|
||||||
|
role: "USER",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
(req as any).auth = {
|
||||||
|
user: { ...mockUser, role: "USER" },
|
||||||
|
};
|
||||||
|
|
||||||
|
await POST(req as any);
|
||||||
|
|
||||||
|
expect(res._getStatusCode()).toBe(403);
|
||||||
|
const data = JSON.parse(res._getData());
|
||||||
|
expect(data.error).toBe("Access denied. Admin role required.");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle database errors gracefully", async () => {
|
||||||
|
const { req, res } = createMocks({
|
||||||
|
method: "POST",
|
||||||
|
body: {
|
||||||
|
email: "test@example.com",
|
||||||
|
role: "USER",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mock database error
|
||||||
|
prisma.user.create = async () => {
|
||||||
|
throw new Error("Database connection failed");
|
||||||
|
};
|
||||||
|
|
||||||
|
(req as any).auth = {
|
||||||
|
user: mockUser,
|
||||||
|
};
|
||||||
|
|
||||||
|
await POST(req as any);
|
||||||
|
|
||||||
|
expect(res._getStatusCode()).toBe(500);
|
||||||
|
const data = JSON.parse(res._getData());
|
||||||
|
expect(data.error).toBe("Internal server error");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle different role types correctly", async () => {
|
||||||
|
const roles = ["USER", "ADMIN", "AUDITOR"];
|
||||||
|
|
||||||
|
for (const role of roles) {
|
||||||
|
const { req, res } = createMocks({
|
||||||
|
method: "POST",
|
||||||
|
body: {
|
||||||
|
email: `${role.toLowerCase()}@example.com`,
|
||||||
|
role: role,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
(req as any).auth = {
|
||||||
|
user: mockUser,
|
||||||
|
};
|
||||||
|
|
||||||
|
await POST(req as any);
|
||||||
|
|
||||||
|
expect(res._getStatusCode()).toBe(201);
|
||||||
|
const data = JSON.parse(res._getData());
|
||||||
|
expect(data.user.role).toBe(role);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should associate user with correct company", async () => {
|
||||||
|
const { req, res } = createMocks({
|
||||||
|
method: "POST",
|
||||||
|
body: {
|
||||||
|
email: "newuser@example.com",
|
||||||
|
role: "USER",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
(req as any).auth = {
|
||||||
|
user: mockUser,
|
||||||
|
};
|
||||||
|
|
||||||
|
await POST(req as any);
|
||||||
|
|
||||||
|
expect(res._getStatusCode()).toBe(201);
|
||||||
|
const data = JSON.parse(res._getData());
|
||||||
|
expect(data.user.companyId).toBe(mockUser.companyId);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Email Validation Edge Cases", () => {
|
||||||
|
it("should handle very long email addresses", async () => {
|
||||||
|
const longEmail = "a".repeat(250) + "@example.com";
|
||||||
|
|
||||||
|
const { req, res } = createMocks({
|
||||||
|
method: "POST",
|
||||||
|
body: {
|
||||||
|
email: longEmail,
|
||||||
|
role: "USER",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
(req as any).auth = {
|
||||||
|
user: mockUser,
|
||||||
|
};
|
||||||
|
|
||||||
|
await POST(req as any);
|
||||||
|
|
||||||
|
expect(res._getStatusCode()).toBe(400);
|
||||||
|
const data = JSON.parse(res._getData());
|
||||||
|
expect(data.error).toContain("Email too long");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle special characters in email", async () => {
|
||||||
|
const specialEmail = "test+tag@example-domain.co.uk";
|
||||||
|
|
||||||
|
const { req, res } = createMocks({
|
||||||
|
method: "POST",
|
||||||
|
body: {
|
||||||
|
email: specialEmail,
|
||||||
|
role: "USER",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
(req as any).auth = {
|
||||||
|
user: mockUser,
|
||||||
|
};
|
||||||
|
|
||||||
|
await POST(req as any);
|
||||||
|
|
||||||
|
expect(res._getStatusCode()).toBe(201);
|
||||||
|
const data = JSON.parse(res._getData());
|
||||||
|
expect(data.user.email).toBe(specialEmail);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should normalize email case", async () => {
|
||||||
|
const { req, res } = createMocks({
|
||||||
|
method: "POST",
|
||||||
|
body: {
|
||||||
|
email: "TEST@EXAMPLE.COM",
|
||||||
|
role: "USER",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
(req as any).auth = {
|
||||||
|
user: mockUser,
|
||||||
|
};
|
||||||
|
|
||||||
|
await POST(req as any);
|
||||||
|
|
||||||
|
expect(res._getStatusCode()).toBe(201);
|
||||||
|
const data = JSON.parse(res._getData());
|
||||||
|
expect(data.user.email).toBe("test@example.com");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Concurrent Request Handling", () => {
|
||||||
|
it("should handle concurrent invitations for the same email", async () => {
|
||||||
|
const email = "concurrent@example.com";
|
||||||
|
|
||||||
|
// Create multiple requests for the same email
|
||||||
|
const requests = Array.from({ length: 3 }, () => {
|
||||||
|
const { req } = createMocks({
|
||||||
|
method: "POST",
|
||||||
|
body: { email, role: "USER" },
|
||||||
|
});
|
||||||
|
(req as any).auth = { user: mockUser };
|
||||||
|
return req;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Execute requests concurrently
|
||||||
|
const results = await Promise.allSettled(
|
||||||
|
requests.map(req => POST(req as any))
|
||||||
|
);
|
||||||
|
|
||||||
|
// Only one should succeed, others should fail with conflict
|
||||||
|
const successful = results.filter(r => r.status === "fulfilled").length;
|
||||||
|
expect(successful).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Rate Limiting", () => {
|
||||||
|
it("should handle multiple rapid invitations", async () => {
|
||||||
|
const emails = [
|
||||||
|
"user1@example.com",
|
||||||
|
"user2@example.com",
|
||||||
|
"user3@example.com",
|
||||||
|
"user4@example.com",
|
||||||
|
"user5@example.com",
|
||||||
|
];
|
||||||
|
|
||||||
|
const results = [];
|
||||||
|
|
||||||
|
for (const email of emails) {
|
||||||
|
const { req, res } = createMocks({
|
||||||
|
method: "POST",
|
||||||
|
body: { email, role: "USER" },
|
||||||
|
});
|
||||||
|
|
||||||
|
(req as any).auth = { user: mockUser };
|
||||||
|
|
||||||
|
await POST(req as any);
|
||||||
|
results.push({
|
||||||
|
email,
|
||||||
|
status: res._getStatusCode(),
|
||||||
|
data: JSON.parse(res._getData()),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// All should succeed (no rate limiting implemented yet)
|
||||||
|
results.forEach(result => {
|
||||||
|
expect(result.status).toBe(201);
|
||||||
|
expect(result.data.user.email).toBe(result.email);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -1,5 +1,6 @@
|
|||||||
// Vitest test setup
|
// Vitest test setup
|
||||||
import { vi } from "vitest";
|
import { vi } from "vitest";
|
||||||
|
import "@testing-library/jest-dom";
|
||||||
|
|
||||||
// Mock console methods to reduce noise in tests
|
// Mock console methods to reduce noise in tests
|
||||||
global.console = {
|
global.console = {
|
||||||
|
|||||||
451
tests/unit/accessibility.test.tsx
Normal file
451
tests/unit/accessibility.test.tsx
Normal file
@ -0,0 +1,451 @@
|
|||||||
|
/**
|
||||||
|
* @vitest-environment jsdom
|
||||||
|
*/
|
||||||
|
import { describe, it, expect, vi } from "vitest";
|
||||||
|
import { render, screen, fireEvent } from "@testing-library/react";
|
||||||
|
import { axe, toHaveNoViolations } from "jest-axe";
|
||||||
|
import { ThemeProvider } from "@/components/theme-provider";
|
||||||
|
import UserManagementPage from "@/app/dashboard/users/page";
|
||||||
|
import SessionViewPage from "@/app/dashboard/sessions/[id]/page";
|
||||||
|
import { useSession } from "next-auth/react";
|
||||||
|
import { useParams } from "next/navigation";
|
||||||
|
|
||||||
|
// Extend Jest matchers
|
||||||
|
expect.extend(toHaveNoViolations);
|
||||||
|
|
||||||
|
// Mock dependencies
|
||||||
|
vi.mock("next-auth/react");
|
||||||
|
vi.mock("next/navigation");
|
||||||
|
const mockUseSession = vi.mocked(useSession);
|
||||||
|
const mockUseParams = vi.mocked(useParams);
|
||||||
|
|
||||||
|
// Mock fetch
|
||||||
|
global.fetch = vi.fn();
|
||||||
|
|
||||||
|
// Test wrapper with theme provider
|
||||||
|
const TestWrapper = ({ children, theme = "light" }: { children: React.ReactNode; theme?: "light" | "dark" }) => (
|
||||||
|
<ThemeProvider attribute="class" defaultTheme={theme} enableSystem={false}>
|
||||||
|
<div className={theme}>{children}</div>
|
||||||
|
</ThemeProvider>
|
||||||
|
);
|
||||||
|
|
||||||
|
describe("Accessibility Tests", () => {
|
||||||
|
describe("User Management Page Accessibility", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mockUseSession.mockReturnValue({
|
||||||
|
data: { user: { role: "ADMIN" } },
|
||||||
|
status: "authenticated",
|
||||||
|
});
|
||||||
|
|
||||||
|
(global.fetch as any).mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: () => Promise.resolve({
|
||||||
|
users: [
|
||||||
|
{ id: "1", email: "admin@example.com", role: "ADMIN" },
|
||||||
|
{ id: "2", email: "user@example.com", role: "USER" },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should have no accessibility violations in light mode", async () => {
|
||||||
|
const { container } = render(
|
||||||
|
<TestWrapper theme="light">
|
||||||
|
<UserManagementPage />
|
||||||
|
</TestWrapper>
|
||||||
|
);
|
||||||
|
|
||||||
|
const results = await axe(container);
|
||||||
|
expect(results).toHaveNoViolations();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should have no accessibility violations in dark mode", async () => {
|
||||||
|
const { container } = render(
|
||||||
|
<TestWrapper theme="dark">
|
||||||
|
<UserManagementPage />
|
||||||
|
</TestWrapper>
|
||||||
|
);
|
||||||
|
|
||||||
|
const results = await axe(container);
|
||||||
|
expect(results).toHaveNoViolations();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should have proper form labels", async () => {
|
||||||
|
render(
|
||||||
|
<TestWrapper>
|
||||||
|
<UserManagementPage />
|
||||||
|
</TestWrapper>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Check for proper form labels
|
||||||
|
const emailInput = screen.getByLabelText("Email");
|
||||||
|
const roleSelect = screen.getByRole("combobox");
|
||||||
|
|
||||||
|
expect(emailInput).toBeInTheDocument();
|
||||||
|
expect(roleSelect).toBeInTheDocument();
|
||||||
|
expect(emailInput).toHaveAttribute("type", "email");
|
||||||
|
expect(emailInput).toHaveAttribute("required");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should support keyboard navigation", async () => {
|
||||||
|
render(
|
||||||
|
<TestWrapper>
|
||||||
|
<UserManagementPage />
|
||||||
|
</TestWrapper>
|
||||||
|
);
|
||||||
|
|
||||||
|
const emailInput = screen.getByLabelText("Email");
|
||||||
|
const roleSelect = screen.getByRole("combobox");
|
||||||
|
const submitButton = screen.getByRole("button", { name: /invite user/i });
|
||||||
|
|
||||||
|
// Test tab navigation
|
||||||
|
emailInput.focus();
|
||||||
|
expect(document.activeElement).toBe(emailInput);
|
||||||
|
|
||||||
|
fireEvent.keyDown(emailInput, { key: "Tab" });
|
||||||
|
expect(document.activeElement).toBe(roleSelect);
|
||||||
|
|
||||||
|
fireEvent.keyDown(roleSelect, { key: "Tab" });
|
||||||
|
expect(document.activeElement).toBe(submitButton);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should have proper ARIA attributes", async () => {
|
||||||
|
render(
|
||||||
|
<TestWrapper>
|
||||||
|
<UserManagementPage />
|
||||||
|
</TestWrapper>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Check table accessibility
|
||||||
|
const table = screen.getByRole("table");
|
||||||
|
expect(table).toBeInTheDocument();
|
||||||
|
|
||||||
|
const columnHeaders = screen.getAllByRole("columnheader");
|
||||||
|
expect(columnHeaders).toHaveLength(3);
|
||||||
|
|
||||||
|
// Check form accessibility
|
||||||
|
const form = screen.getByRole("form");
|
||||||
|
expect(form).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should have proper heading structure", async () => {
|
||||||
|
render(
|
||||||
|
<TestWrapper>
|
||||||
|
<UserManagementPage />
|
||||||
|
</TestWrapper>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Check for proper heading hierarchy
|
||||||
|
const mainHeading = screen.getByRole("heading", { level: 1 });
|
||||||
|
expect(mainHeading).toHaveTextContent("User Management");
|
||||||
|
|
||||||
|
const subHeadings = screen.getAllByRole("heading", { level: 2 });
|
||||||
|
expect(subHeadings.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Session Details Page Accessibility", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mockUseSession.mockReturnValue({
|
||||||
|
data: { user: { role: "ADMIN" } },
|
||||||
|
status: "authenticated",
|
||||||
|
});
|
||||||
|
|
||||||
|
mockUseParams.mockReturnValue({
|
||||||
|
id: "test-session-id",
|
||||||
|
});
|
||||||
|
|
||||||
|
(global.fetch as any).mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: () => Promise.resolve({
|
||||||
|
session: {
|
||||||
|
id: "test-session-id",
|
||||||
|
sessionId: "test-session-id",
|
||||||
|
startTime: new Date().toISOString(),
|
||||||
|
endTime: new Date().toISOString(),
|
||||||
|
category: "SALARY_COMPENSATION",
|
||||||
|
language: "en",
|
||||||
|
country: "US",
|
||||||
|
sentiment: "positive",
|
||||||
|
messagesSent: 5,
|
||||||
|
userId: "user-123",
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
id: "msg-1",
|
||||||
|
content: "Hello",
|
||||||
|
role: "user",
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should have no accessibility violations in light mode", async () => {
|
||||||
|
const { container } = render(
|
||||||
|
<TestWrapper theme="light">
|
||||||
|
<SessionViewPage />
|
||||||
|
</TestWrapper>
|
||||||
|
);
|
||||||
|
|
||||||
|
const results = await axe(container);
|
||||||
|
expect(results).toHaveNoViolations();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should have no accessibility violations in dark mode", async () => {
|
||||||
|
const { container } = render(
|
||||||
|
<TestWrapper theme="dark">
|
||||||
|
<SessionViewPage />
|
||||||
|
</TestWrapper>
|
||||||
|
);
|
||||||
|
|
||||||
|
const results = await axe(container);
|
||||||
|
expect(results).toHaveNoViolations();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should have proper navigation links", async () => {
|
||||||
|
render(
|
||||||
|
<TestWrapper>
|
||||||
|
<SessionViewPage />
|
||||||
|
</TestWrapper>
|
||||||
|
);
|
||||||
|
|
||||||
|
const backLink = screen.getByRole("button", { name: /return to sessions list/i });
|
||||||
|
expect(backLink).toBeInTheDocument();
|
||||||
|
expect(backLink).toHaveAttribute("aria-label", "Return to sessions list");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should have proper badge accessibility", async () => {
|
||||||
|
render(
|
||||||
|
<TestWrapper>
|
||||||
|
<SessionViewPage />
|
||||||
|
</TestWrapper>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Wait for data to load and check badges
|
||||||
|
await screen.findByText("Session Details");
|
||||||
|
|
||||||
|
const badges = screen.getAllByTestId(/badge/i);
|
||||||
|
badges.forEach((badge) => {
|
||||||
|
// Badges should have proper contrast and be readable
|
||||||
|
expect(badge).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Theme Switching Accessibility", () => {
|
||||||
|
it("should maintain accessibility when switching themes", async () => {
|
||||||
|
mockUseSession.mockReturnValue({
|
||||||
|
data: { user: { role: "ADMIN" } },
|
||||||
|
status: "authenticated",
|
||||||
|
});
|
||||||
|
|
||||||
|
(global.fetch as any).mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: () => Promise.resolve({ users: [] }),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test light theme
|
||||||
|
const { container, rerender } = render(
|
||||||
|
<TestWrapper theme="light">
|
||||||
|
<UserManagementPage />
|
||||||
|
</TestWrapper>
|
||||||
|
);
|
||||||
|
|
||||||
|
let results = await axe(container);
|
||||||
|
expect(results).toHaveNoViolations();
|
||||||
|
|
||||||
|
// Test dark theme
|
||||||
|
rerender(
|
||||||
|
<TestWrapper theme="dark">
|
||||||
|
<UserManagementPage />
|
||||||
|
</TestWrapper>
|
||||||
|
);
|
||||||
|
|
||||||
|
results = await axe(container);
|
||||||
|
expect(results).toHaveNoViolations();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should preserve focus when switching themes", async () => {
|
||||||
|
mockUseSession.mockReturnValue({
|
||||||
|
data: { user: { role: "ADMIN" } },
|
||||||
|
status: "authenticated",
|
||||||
|
});
|
||||||
|
|
||||||
|
(global.fetch as any).mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: () => Promise.resolve({ users: [] }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { rerender } = render(
|
||||||
|
<TestWrapper theme="light">
|
||||||
|
<UserManagementPage />
|
||||||
|
</TestWrapper>
|
||||||
|
);
|
||||||
|
|
||||||
|
const emailInput = screen.getByLabelText("Email");
|
||||||
|
emailInput.focus();
|
||||||
|
expect(document.activeElement).toBe(emailInput);
|
||||||
|
|
||||||
|
// Switch theme
|
||||||
|
rerender(
|
||||||
|
<TestWrapper theme="dark">
|
||||||
|
<UserManagementPage />
|
||||||
|
</TestWrapper>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Focus should be maintained (or at least not cause errors)
|
||||||
|
const newEmailInput = screen.getByLabelText("Email");
|
||||||
|
expect(newEmailInput).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Keyboard Navigation", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mockUseSession.mockReturnValue({
|
||||||
|
data: { user: { role: "ADMIN" } },
|
||||||
|
status: "authenticated",
|
||||||
|
});
|
||||||
|
|
||||||
|
(global.fetch as any).mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: () => Promise.resolve({
|
||||||
|
users: [
|
||||||
|
{ id: "1", email: "admin@example.com", role: "ADMIN" },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should support tab navigation through all interactive elements", async () => {
|
||||||
|
render(
|
||||||
|
<TestWrapper>
|
||||||
|
<UserManagementPage />
|
||||||
|
</TestWrapper>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get all focusable elements
|
||||||
|
const focusableElements = screen.getAllByRole("button").concat(
|
||||||
|
screen.getAllByRole("textbox"),
|
||||||
|
screen.getAllByRole("combobox")
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(focusableElements.length).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
// Each element should be focusable
|
||||||
|
focusableElements.forEach((element) => {
|
||||||
|
element.focus();
|
||||||
|
expect(document.activeElement).toBe(element);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should support Enter key activation", async () => {
|
||||||
|
render(
|
||||||
|
<TestWrapper>
|
||||||
|
<UserManagementPage />
|
||||||
|
</TestWrapper>
|
||||||
|
);
|
||||||
|
|
||||||
|
const submitButton = screen.getByRole("button", { name: /invite user/i });
|
||||||
|
|
||||||
|
// Focus and press Enter
|
||||||
|
submitButton.focus();
|
||||||
|
fireEvent.keyDown(submitButton, { key: "Enter" });
|
||||||
|
|
||||||
|
// Button should respond to Enter key
|
||||||
|
expect(submitButton).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should have visible focus indicators", async () => {
|
||||||
|
render(
|
||||||
|
<TestWrapper>
|
||||||
|
<UserManagementPage />
|
||||||
|
</TestWrapper>
|
||||||
|
);
|
||||||
|
|
||||||
|
const emailInput = screen.getByLabelText("Email");
|
||||||
|
|
||||||
|
emailInput.focus();
|
||||||
|
|
||||||
|
// Check that the element has focus styles
|
||||||
|
expect(emailInput).toHaveFocus();
|
||||||
|
|
||||||
|
// The focus should be visible (checked via CSS classes in real implementation)
|
||||||
|
expect(emailInput).toHaveClass(/focus/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Screen Reader Support", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mockUseSession.mockReturnValue({
|
||||||
|
data: { user: { role: "ADMIN" } },
|
||||||
|
status: "authenticated",
|
||||||
|
});
|
||||||
|
|
||||||
|
(global.fetch as any).mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: () => Promise.resolve({
|
||||||
|
users: [
|
||||||
|
{ id: "1", email: "admin@example.com", role: "ADMIN" },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should have proper landmark roles", async () => {
|
||||||
|
render(
|
||||||
|
<TestWrapper>
|
||||||
|
<UserManagementPage />
|
||||||
|
</TestWrapper>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Check for semantic landmarks
|
||||||
|
const main = screen.getByRole("main");
|
||||||
|
expect(main).toBeInTheDocument();
|
||||||
|
|
||||||
|
const form = screen.getByRole("form");
|
||||||
|
expect(form).toBeInTheDocument();
|
||||||
|
|
||||||
|
const table = screen.getByRole("table");
|
||||||
|
expect(table).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should provide proper announcements for dynamic content", async () => {
|
||||||
|
const { rerender } = render(
|
||||||
|
<TestWrapper>
|
||||||
|
<UserManagementPage />
|
||||||
|
</TestWrapper>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Check for live regions
|
||||||
|
const liveRegions = screen.getAllByRole("status");
|
||||||
|
expect(liveRegions.length).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
// Simulate an error state
|
||||||
|
(global.fetch as any).mockRejectedValueOnce(new Error("Network error"));
|
||||||
|
|
||||||
|
rerender(
|
||||||
|
<TestWrapper>
|
||||||
|
<UserManagementPage />
|
||||||
|
</TestWrapper>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Error should be announced
|
||||||
|
const errorMessage = screen.getByText(/failed to load users/i);
|
||||||
|
expect(errorMessage).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should have descriptive button labels", async () => {
|
||||||
|
render(
|
||||||
|
<TestWrapper>
|
||||||
|
<UserManagementPage />
|
||||||
|
</TestWrapper>
|
||||||
|
);
|
||||||
|
|
||||||
|
const inviteButton = screen.getByRole("button", { name: /invite user/i });
|
||||||
|
expect(inviteButton).toBeInTheDocument();
|
||||||
|
expect(inviteButton).toHaveAccessibleName();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -1,21 +1,21 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
import { authOptions } from '../../app/api/auth/[...nextauth]/route';
|
import { authOptions } from "../../app/api/auth/[...nextauth]/route";
|
||||||
import { PrismaClient } from '@prisma/client';
|
import { PrismaClient } from "@prisma/client";
|
||||||
import bcrypt from 'bcryptjs';
|
import bcrypt from "bcryptjs";
|
||||||
|
|
||||||
// Mock PrismaClient
|
// Mock PrismaClient
|
||||||
vi.mock('../../lib/prisma', () => ({
|
vi.mock("../../lib/prisma", () => ({
|
||||||
prisma: new PrismaClient(),
|
prisma: new PrismaClient(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Mock bcryptjs
|
// Mock bcryptjs
|
||||||
vi.mock('bcryptjs', () => ({
|
vi.mock("bcryptjs", () => ({
|
||||||
default: {
|
default: {
|
||||||
compare: vi.fn(),
|
compare: vi.fn(),
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
describe('NextAuth Credentials Provider authorize function', () => {
|
describe("NextAuth Credentials Provider authorize function", () => {
|
||||||
let mockFindUnique: vi.Mock;
|
let mockFindUnique: vi.Mock;
|
||||||
let mockBcryptCompare: vi.Mock;
|
let mockBcryptCompare: vi.Mock;
|
||||||
|
|
||||||
@ -29,72 +29,90 @@ describe('NextAuth Credentials Provider authorize function', () => {
|
|||||||
|
|
||||||
const authorize = authOptions.providers[0].authorize;
|
const authorize = authOptions.providers[0].authorize;
|
||||||
|
|
||||||
it('should return null if email or password are not provided', async () => {
|
it("should return null if email or password are not provided", async () => {
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
const result1 = await authorize({ email: 'test@example.com', password: '' });
|
const result1 = await authorize({
|
||||||
|
email: "test@example.com",
|
||||||
|
password: "",
|
||||||
|
});
|
||||||
expect(result1).toBeNull();
|
expect(result1).toBeNull();
|
||||||
expect(mockFindUnique).not.toHaveBeenCalled();
|
expect(mockFindUnique).not.toHaveBeenCalled();
|
||||||
|
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
const result2 = await authorize({ email: '', password: 'password' });
|
const result2 = await authorize({ email: "", password: "password" });
|
||||||
expect(result2).toBeNull();
|
expect(result2).toBeNull();
|
||||||
expect(mockFindUnique).not.toHaveBeenCalled();
|
expect(mockFindUnique).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return null if user is not found', async () => {
|
it("should return null if user is not found", async () => {
|
||||||
mockFindUnique.mockResolvedValue(null);
|
mockFindUnique.mockResolvedValue(null);
|
||||||
|
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
const result = await authorize({ email: 'nonexistent@example.com', password: 'password' });
|
const result = await authorize({
|
||||||
|
email: "nonexistent@example.com",
|
||||||
|
password: "password",
|
||||||
|
});
|
||||||
expect(result).toBeNull();
|
expect(result).toBeNull();
|
||||||
expect(mockFindUnique).toHaveBeenCalledWith({
|
expect(mockFindUnique).toHaveBeenCalledWith({
|
||||||
where: { email: 'nonexistent@example.com' },
|
where: { email: "nonexistent@example.com" },
|
||||||
});
|
});
|
||||||
expect(mockBcryptCompare).not.toHaveBeenCalled();
|
expect(mockBcryptCompare).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return null if password does not match', async () => {
|
it("should return null if password does not match", async () => {
|
||||||
const mockUser = {
|
const mockUser = {
|
||||||
id: 'user123',
|
id: "user123",
|
||||||
email: 'test@example.com',
|
email: "test@example.com",
|
||||||
password: 'hashed_password',
|
password: "hashed_password",
|
||||||
companyId: 'company123',
|
companyId: "company123",
|
||||||
role: 'USER',
|
role: "USER",
|
||||||
};
|
};
|
||||||
mockFindUnique.mockResolvedValue(mockUser);
|
mockFindUnique.mockResolvedValue(mockUser);
|
||||||
mockBcryptCompare.mockResolvedValue(false);
|
mockBcryptCompare.mockResolvedValue(false);
|
||||||
|
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
const result = await authorize({ email: 'test@example.com', password: 'wrong_password' });
|
const result = await authorize({
|
||||||
|
email: "test@example.com",
|
||||||
|
password: "wrong_password",
|
||||||
|
});
|
||||||
expect(result).toBeNull();
|
expect(result).toBeNull();
|
||||||
expect(mockFindUnique).toHaveBeenCalledWith({
|
expect(mockFindUnique).toHaveBeenCalledWith({
|
||||||
where: { email: 'test@example.com' },
|
where: { email: "test@example.com" },
|
||||||
});
|
});
|
||||||
expect(mockBcryptCompare).toHaveBeenCalledWith('wrong_password', 'hashed_password');
|
expect(mockBcryptCompare).toHaveBeenCalledWith(
|
||||||
|
"wrong_password",
|
||||||
|
"hashed_password"
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return user object if credentials are valid', async () => {
|
it("should return user object if credentials are valid", async () => {
|
||||||
const mockUser = {
|
const mockUser = {
|
||||||
id: 'user123',
|
id: "user123",
|
||||||
email: 'test@example.com',
|
email: "test@example.com",
|
||||||
password: 'hashed_password',
|
password: "hashed_password",
|
||||||
companyId: 'company123',
|
companyId: "company123",
|
||||||
role: 'USER',
|
role: "USER",
|
||||||
};
|
};
|
||||||
mockFindUnique.mockResolvedValue(mockUser);
|
mockFindUnique.mockResolvedValue(mockUser);
|
||||||
mockBcryptCompare.mockResolvedValue(true);
|
mockBcryptCompare.mockResolvedValue(true);
|
||||||
|
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
const result = await authorize({ email: 'test@example.com', password: 'correct_password' });
|
const result = await authorize({
|
||||||
|
email: "test@example.com",
|
||||||
|
password: "correct_password",
|
||||||
|
});
|
||||||
expect(result).toEqual({
|
expect(result).toEqual({
|
||||||
id: 'user123',
|
id: "user123",
|
||||||
email: 'test@example.com',
|
email: "test@example.com",
|
||||||
companyId: 'company123',
|
companyId: "company123",
|
||||||
role: 'USER',
|
role: "USER",
|
||||||
});
|
});
|
||||||
expect(mockFindUnique).toHaveBeenCalledWith({
|
expect(mockFindUnique).toHaveBeenCalledWith({
|
||||||
where: { email: 'test@example.com' },
|
where: { email: "test@example.com" },
|
||||||
});
|
});
|
||||||
expect(mockBcryptCompare).toHaveBeenCalledWith('correct_password', 'hashed_password');
|
expect(mockBcryptCompare).toHaveBeenCalledWith(
|
||||||
|
"correct_password",
|
||||||
|
"hashed_password"
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
265
tests/unit/format-enums.test.ts
Normal file
265
tests/unit/format-enums.test.ts
Normal file
@ -0,0 +1,265 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { formatEnumValue, formatCategory } from "@/lib/format-enums";
|
||||||
|
|
||||||
|
describe("Format Enums Utility", () => {
|
||||||
|
describe("formatEnumValue", () => {
|
||||||
|
it("should format known enum values correctly", () => {
|
||||||
|
const knownEnums = [
|
||||||
|
{ input: "SALARY_COMPENSATION", expected: "Salary & Compensation" },
|
||||||
|
{ input: "SCHEDULE_HOURS", expected: "Schedule & Hours" },
|
||||||
|
{ input: "LEAVE_VACATION", expected: "Leave & Vacation" },
|
||||||
|
{ input: "SICK_LEAVE_RECOVERY", expected: "Sick Leave & Recovery" },
|
||||||
|
{ input: "BENEFITS_INSURANCE", expected: "Benefits Insurance" },
|
||||||
|
{ input: "CAREER_DEVELOPMENT", expected: "Career Development" },
|
||||||
|
{ input: "TEAM_COLLABORATION", expected: "Team Collaboration" },
|
||||||
|
{ input: "COMPANY_POLICIES", expected: "Company Policies" },
|
||||||
|
{ input: "WORKPLACE_FACILITIES", expected: "Workplace Facilities" },
|
||||||
|
{ input: "TECHNOLOGY_EQUIPMENT", expected: "Technology Equipment" },
|
||||||
|
{ input: "PERFORMANCE_FEEDBACK", expected: "Performance Feedback" },
|
||||||
|
{ input: "TRAINING_ONBOARDING", expected: "Training Onboarding" },
|
||||||
|
{ input: "COMPLIANCE_LEGAL", expected: "Compliance Legal" },
|
||||||
|
{ input: "WORKWEAR_STAFF_PASS", expected: "Workwear & Staff Pass" },
|
||||||
|
{ input: "TEAM_CONTACTS", expected: "Team & Contacts" },
|
||||||
|
{ input: "PERSONAL_QUESTIONS", expected: "Personal Questions" },
|
||||||
|
{ input: "ACCESS_LOGIN", expected: "Access & Login" },
|
||||||
|
{ input: "UNRECOGNIZED_OTHER", expected: "General Inquiry" },
|
||||||
|
];
|
||||||
|
|
||||||
|
knownEnums.forEach(({ input, expected }) => {
|
||||||
|
expect(formatEnumValue(input)).toBe(expected);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle unknown enum values by formatting them", () => {
|
||||||
|
const unknownEnums = [
|
||||||
|
{ input: "UNKNOWN_ENUM", expected: "Unknown Enum" },
|
||||||
|
{ input: "ANOTHER_TEST_CASE", expected: "Another Test Case" },
|
||||||
|
{ input: "SINGLE", expected: "Single" },
|
||||||
|
{ input: "MULTIPLE_WORDS_HERE", expected: "Multiple Words Here" },
|
||||||
|
];
|
||||||
|
|
||||||
|
unknownEnums.forEach(({ input, expected }) => {
|
||||||
|
expect(formatEnumValue(input)).toBe(expected);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle null and undefined values", () => {
|
||||||
|
expect(formatEnumValue(null)).toBe(null);
|
||||||
|
expect(formatEnumValue(undefined)).toBe(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle empty string", () => {
|
||||||
|
expect(formatEnumValue("")).toBe(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle lowercase enum values", () => {
|
||||||
|
expect(formatEnumValue("salary_compensation")).toBe("Salary Compensation");
|
||||||
|
expect(formatEnumValue("schedule_hours")).toBe("Schedule Hours");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle mixed case enum values", () => {
|
||||||
|
expect(formatEnumValue("Salary_COMPENSATION")).toBe("Salary Compensation");
|
||||||
|
expect(formatEnumValue("Schedule_Hours")).toBe("Schedule Hours");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle values without underscores", () => {
|
||||||
|
expect(formatEnumValue("SALARY")).toBe("Salary");
|
||||||
|
expect(formatEnumValue("ADMIN")).toBe("Admin");
|
||||||
|
expect(formatEnumValue("USER")).toBe("User");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle values with multiple consecutive underscores", () => {
|
||||||
|
expect(formatEnumValue("SALARY___COMPENSATION")).toBe("Salary Compensation");
|
||||||
|
expect(formatEnumValue("TEST__CASE")).toBe("Test Case");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle values with leading/trailing underscores", () => {
|
||||||
|
expect(formatEnumValue("_SALARY_COMPENSATION_")).toBe(" Salary Compensation ");
|
||||||
|
expect(formatEnumValue("__TEST_CASE__")).toBe(" Test Case ");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle single character enum values", () => {
|
||||||
|
expect(formatEnumValue("A")).toBe("A");
|
||||||
|
expect(formatEnumValue("X_Y_Z")).toBe("X Y Z");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle numeric characters in enum values", () => {
|
||||||
|
expect(formatEnumValue("VERSION_2_0")).toBe("Version 2 0");
|
||||||
|
expect(formatEnumValue("TEST_123_CASE")).toBe("Test 123 Case");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should be case insensitive for known enums", () => {
|
||||||
|
expect(formatEnumValue("salary_compensation")).toBe("Salary Compensation");
|
||||||
|
expect(formatEnumValue("SALARY_COMPENSATION")).toBe("Salary & Compensation");
|
||||||
|
expect(formatEnumValue("Salary_Compensation")).toBe("Salary Compensation");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("formatCategory", () => {
|
||||||
|
it("should be an alias for formatEnumValue", () => {
|
||||||
|
const testValues = [
|
||||||
|
"SALARY_COMPENSATION",
|
||||||
|
"SCHEDULE_HOURS",
|
||||||
|
"UNKNOWN_ENUM",
|
||||||
|
null,
|
||||||
|
undefined,
|
||||||
|
"",
|
||||||
|
];
|
||||||
|
|
||||||
|
testValues.forEach((value) => {
|
||||||
|
expect(formatCategory(value)).toBe(formatEnumValue(value));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should format category-specific enum values", () => {
|
||||||
|
const categoryEnums = [
|
||||||
|
{ input: "SALARY_COMPENSATION", expected: "Salary & Compensation" },
|
||||||
|
{ input: "BENEFITS_INSURANCE", expected: "Benefits Insurance" },
|
||||||
|
{ input: "UNRECOGNIZED_OTHER", expected: "General Inquiry" },
|
||||||
|
{ input: "ACCESS_LOGIN", expected: "Access & Login" },
|
||||||
|
];
|
||||||
|
|
||||||
|
categoryEnums.forEach(({ input, expected }) => {
|
||||||
|
expect(formatCategory(input)).toBe(expected);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Edge Cases and Performance", () => {
|
||||||
|
it("should handle very long enum values", () => {
|
||||||
|
const longEnum = "A".repeat(100) + "_" + "B".repeat(100);
|
||||||
|
const result = formatEnumValue(longEnum);
|
||||||
|
|
||||||
|
expect(result).toBeTruthy();
|
||||||
|
expect(result?.length).toBeGreaterThan(200);
|
||||||
|
expect(result?.includes(" ")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle special characters gracefully", () => {
|
||||||
|
// These shouldn't be real enum values, but should not crash
|
||||||
|
expect(formatEnumValue("TEST-CASE")).toBe("Test-Case");
|
||||||
|
expect(formatEnumValue("TEST.CASE")).toBe("Test.Case");
|
||||||
|
expect(formatEnumValue("TEST@CASE")).toBe("Test@Case");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle unicode characters", () => {
|
||||||
|
expect(formatEnumValue("TEST_CAFÉ")).toBe("Test Café");
|
||||||
|
expect(formatEnumValue("RÉSUMÉ_TYPE")).toBe("RéSumé Type");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should be performant with many calls", () => {
|
||||||
|
const testEnum = "SALARY_COMPENSATION";
|
||||||
|
const iterations = 1000;
|
||||||
|
|
||||||
|
const startTime = performance.now();
|
||||||
|
|
||||||
|
for (let i = 0; i < iterations; i++) {
|
||||||
|
formatEnumValue(testEnum);
|
||||||
|
}
|
||||||
|
|
||||||
|
const endTime = performance.now();
|
||||||
|
const duration = endTime - startTime;
|
||||||
|
|
||||||
|
// Should complete 1000 calls in reasonable time (less than 100ms)
|
||||||
|
expect(duration).toBeLessThan(100);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should be consistent with repeated calls", () => {
|
||||||
|
const testCases = [
|
||||||
|
"SALARY_COMPENSATION",
|
||||||
|
"UNKNOWN_ENUM_VALUE",
|
||||||
|
null,
|
||||||
|
undefined,
|
||||||
|
"",
|
||||||
|
];
|
||||||
|
|
||||||
|
testCases.forEach((testCase) => {
|
||||||
|
const result1 = formatEnumValue(testCase);
|
||||||
|
const result2 = formatEnumValue(testCase);
|
||||||
|
const result3 = formatEnumValue(testCase);
|
||||||
|
|
||||||
|
expect(result1).toBe(result2);
|
||||||
|
expect(result2).toBe(result3);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Integration with UI Components", () => {
|
||||||
|
it("should provide user-friendly text for dropdowns", () => {
|
||||||
|
const dropdownOptions = [
|
||||||
|
"SALARY_COMPENSATION",
|
||||||
|
"SCHEDULE_HOURS",
|
||||||
|
"LEAVE_VACATION",
|
||||||
|
"BENEFITS_INSURANCE",
|
||||||
|
];
|
||||||
|
|
||||||
|
const formattedOptions = dropdownOptions.map(option => ({
|
||||||
|
value: option,
|
||||||
|
label: formatEnumValue(option),
|
||||||
|
}));
|
||||||
|
|
||||||
|
formattedOptions.forEach(option => {
|
||||||
|
expect(option.label).toBeTruthy();
|
||||||
|
expect(option.label).not.toContain("_");
|
||||||
|
expect(option.label?.[0]).toBe(option.label?.[0]?.toUpperCase());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should provide readable text for badges and labels", () => {
|
||||||
|
const badgeValues = [
|
||||||
|
"ADMIN",
|
||||||
|
"USER",
|
||||||
|
"AUDITOR",
|
||||||
|
"UNRECOGNIZED_OTHER",
|
||||||
|
];
|
||||||
|
|
||||||
|
badgeValues.forEach(value => {
|
||||||
|
const formatted = formatEnumValue(value);
|
||||||
|
expect(formatted).toBeTruthy();
|
||||||
|
expect(formatted?.length).toBeGreaterThan(0);
|
||||||
|
// Should be suitable for display in UI
|
||||||
|
expect(formatted).not.toMatch(/^[_\s]/);
|
||||||
|
expect(formatted).not.toMatch(/[_\s]$/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle form validation error messages", () => {
|
||||||
|
// When no value is selected, should return null for proper handling
|
||||||
|
expect(formatEnumValue(null)).toBe(null);
|
||||||
|
expect(formatEnumValue(undefined)).toBe(null);
|
||||||
|
expect(formatEnumValue("")).toBe(null);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Backwards Compatibility", () => {
|
||||||
|
it("should maintain compatibility with legacy enum values", () => {
|
||||||
|
// Test some older enum patterns that might exist
|
||||||
|
const legacyEnums = [
|
||||||
|
{ input: "OTHER", expected: "Other" },
|
||||||
|
{ input: "GENERAL", expected: "General" },
|
||||||
|
{ input: "MISC", expected: "Misc" },
|
||||||
|
];
|
||||||
|
|
||||||
|
legacyEnums.forEach(({ input, expected }) => {
|
||||||
|
expect(formatEnumValue(input)).toBe(expected);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle enum values that might be added in the future", () => {
|
||||||
|
// Future enum values should still be formatted reasonably
|
||||||
|
const futureEnums = [
|
||||||
|
"REMOTE_WORK_POLICY",
|
||||||
|
"SUSTAINABILITY_INITIATIVES",
|
||||||
|
"DIVERSITY_INCLUSION",
|
||||||
|
"MENTAL_HEALTH_SUPPORT",
|
||||||
|
];
|
||||||
|
|
||||||
|
futureEnums.forEach(value => {
|
||||||
|
const result = formatEnumValue(value);
|
||||||
|
expect(result).toBeTruthy();
|
||||||
|
expect(result).not.toContain("_");
|
||||||
|
expect(result?.[0]).toBe(result?.[0]?.toUpperCase());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
535
tests/unit/keyboard-navigation.test.tsx
Normal file
535
tests/unit/keyboard-navigation.test.tsx
Normal file
@ -0,0 +1,535 @@
|
|||||||
|
/**
|
||||||
|
* @vitest-environment jsdom
|
||||||
|
*/
|
||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import { render, screen, fireEvent } from "@testing-library/react";
|
||||||
|
import { useSession } from "next-auth/react";
|
||||||
|
import { useParams } from "next/navigation";
|
||||||
|
import UserManagementPage from "@/app/dashboard/users/page";
|
||||||
|
import SessionViewPage from "@/app/dashboard/sessions/[id]/page";
|
||||||
|
import ModernDonutChart from "@/components/charts/donut-chart";
|
||||||
|
|
||||||
|
// Mock dependencies
|
||||||
|
vi.mock("next-auth/react");
|
||||||
|
vi.mock("next/navigation");
|
||||||
|
const mockUseSession = vi.mocked(useSession);
|
||||||
|
const mockUseParams = vi.mocked(useParams);
|
||||||
|
|
||||||
|
// Mock fetch
|
||||||
|
global.fetch = vi.fn();
|
||||||
|
|
||||||
|
describe("Keyboard Navigation Tests", () => {
|
||||||
|
describe("User Management Page Keyboard Navigation", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mockUseSession.mockReturnValue({
|
||||||
|
data: { user: { role: "ADMIN" } },
|
||||||
|
status: "authenticated",
|
||||||
|
});
|
||||||
|
|
||||||
|
(global.fetch as any).mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: () => Promise.resolve({
|
||||||
|
users: [
|
||||||
|
{ id: "1", email: "admin@example.com", role: "ADMIN" },
|
||||||
|
{ id: "2", email: "user@example.com", role: "USER" },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should support tab navigation through form elements", async () => {
|
||||||
|
render(<UserManagementPage />);
|
||||||
|
|
||||||
|
await screen.findByText("User Management");
|
||||||
|
|
||||||
|
const emailInput = screen.getByLabelText("Email");
|
||||||
|
const roleSelect = screen.getByRole("combobox");
|
||||||
|
const submitButton = screen.getByRole("button", { name: /invite user/i });
|
||||||
|
|
||||||
|
// Test tab order
|
||||||
|
emailInput.focus();
|
||||||
|
expect(document.activeElement).toBe(emailInput);
|
||||||
|
|
||||||
|
fireEvent.keyDown(emailInput, { key: "Tab" });
|
||||||
|
// Role select should be focused (though actual focus behavior depends on Select component)
|
||||||
|
|
||||||
|
// Tab to submit button
|
||||||
|
fireEvent.keyDown(roleSelect, { key: "Tab" });
|
||||||
|
expect(document.activeElement).toBe(submitButton);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should support Enter key for form submission", async () => {
|
||||||
|
render(<UserManagementPage />);
|
||||||
|
|
||||||
|
await screen.findByText("User Management");
|
||||||
|
|
||||||
|
const emailInput = screen.getByLabelText("Email");
|
||||||
|
const submitButton = screen.getByRole("button", { name: /invite user/i });
|
||||||
|
|
||||||
|
// Fill out form
|
||||||
|
fireEvent.change(emailInput, { target: { value: "test@example.com" } });
|
||||||
|
|
||||||
|
// Mock successful submission
|
||||||
|
(global.fetch as any).mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
json: () => Promise.resolve({
|
||||||
|
users: [
|
||||||
|
{ id: "1", email: "admin@example.com", role: "ADMIN" },
|
||||||
|
{ id: "2", email: "user@example.com", role: "USER" },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
}).mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
json: () => Promise.resolve({ message: "User invited successfully" }),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Submit with Enter key
|
||||||
|
fireEvent.keyDown(submitButton, { key: "Enter" });
|
||||||
|
|
||||||
|
// Form should be submitted
|
||||||
|
expect(global.fetch).toHaveBeenCalledWith(
|
||||||
|
"/api/dashboard/users",
|
||||||
|
expect.objectContaining({
|
||||||
|
method: "POST",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should support Space key for button activation", async () => {
|
||||||
|
render(<UserManagementPage />);
|
||||||
|
|
||||||
|
await screen.findByText("User Management");
|
||||||
|
|
||||||
|
const submitButton = screen.getByRole("button", { name: /invite user/i });
|
||||||
|
|
||||||
|
// Mock form data
|
||||||
|
const emailInput = screen.getByLabelText("Email");
|
||||||
|
fireEvent.change(emailInput, { target: { value: "test@example.com" } });
|
||||||
|
|
||||||
|
// Mock successful submission
|
||||||
|
(global.fetch as any).mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
json: () => Promise.resolve({
|
||||||
|
users: [
|
||||||
|
{ id: "1", email: "admin@example.com", role: "ADMIN" },
|
||||||
|
{ id: "2", email: "user@example.com", role: "USER" },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
}).mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
json: () => Promise.resolve({ message: "User invited successfully" }),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Activate with Space key
|
||||||
|
submitButton.focus();
|
||||||
|
fireEvent.keyDown(submitButton, { key: " " });
|
||||||
|
|
||||||
|
// Should trigger form submission
|
||||||
|
expect(global.fetch).toHaveBeenCalledWith(
|
||||||
|
"/api/dashboard/users",
|
||||||
|
expect.objectContaining({
|
||||||
|
method: "POST",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should have visible focus indicators", async () => {
|
||||||
|
render(<UserManagementPage />);
|
||||||
|
|
||||||
|
await screen.findByText("User Management");
|
||||||
|
|
||||||
|
const emailInput = screen.getByLabelText("Email");
|
||||||
|
const submitButton = screen.getByRole("button", { name: /invite user/i });
|
||||||
|
|
||||||
|
// Focus elements and check for focus indicators
|
||||||
|
emailInput.focus();
|
||||||
|
expect(emailInput).toHaveFocus();
|
||||||
|
expect(emailInput.className).toMatch(/focus/i);
|
||||||
|
|
||||||
|
submitButton.focus();
|
||||||
|
expect(submitButton).toHaveFocus();
|
||||||
|
expect(submitButton.className).toMatch(/focus/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should support Escape key for form reset", async () => {
|
||||||
|
render(<UserManagementPage />);
|
||||||
|
|
||||||
|
await screen.findByText("User Management");
|
||||||
|
|
||||||
|
const emailInput = screen.getByLabelText("Email") as HTMLInputElement;
|
||||||
|
|
||||||
|
// Enter some text
|
||||||
|
fireEvent.change(emailInput, { target: { value: "test@example.com" } });
|
||||||
|
expect(emailInput.value).toBe("test@example.com");
|
||||||
|
|
||||||
|
// Press Escape
|
||||||
|
fireEvent.keyDown(emailInput, { key: "Escape" });
|
||||||
|
|
||||||
|
// Field should not be cleared by Escape (browser default behavior)
|
||||||
|
// But it should not cause any errors
|
||||||
|
expect(emailInput.value).toBe("test@example.com");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should support arrow keys in select elements", async () => {
|
||||||
|
render(<UserManagementPage />);
|
||||||
|
|
||||||
|
await screen.findByText("User Management");
|
||||||
|
|
||||||
|
const roleSelect = screen.getByRole("combobox");
|
||||||
|
|
||||||
|
// Focus the select
|
||||||
|
roleSelect.focus();
|
||||||
|
expect(roleSelect).toHaveFocus();
|
||||||
|
|
||||||
|
// Arrow keys should work (implementation depends on Select component)
|
||||||
|
fireEvent.keyDown(roleSelect, { key: "ArrowDown" });
|
||||||
|
fireEvent.keyDown(roleSelect, { key: "ArrowUp" });
|
||||||
|
|
||||||
|
// Should not throw errors
|
||||||
|
expect(roleSelect).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Session Details Page Keyboard Navigation", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mockUseSession.mockReturnValue({
|
||||||
|
data: { user: { role: "ADMIN" } },
|
||||||
|
status: "authenticated",
|
||||||
|
});
|
||||||
|
|
||||||
|
mockUseParams.mockReturnValue({
|
||||||
|
id: "test-session-id",
|
||||||
|
});
|
||||||
|
|
||||||
|
(global.fetch as any).mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: () => Promise.resolve({
|
||||||
|
session: {
|
||||||
|
id: "test-session-id",
|
||||||
|
sessionId: "test-session-id",
|
||||||
|
startTime: new Date().toISOString(),
|
||||||
|
endTime: new Date().toISOString(),
|
||||||
|
category: "SALARY_COMPENSATION",
|
||||||
|
language: "en",
|
||||||
|
country: "US",
|
||||||
|
sentiment: "positive",
|
||||||
|
messagesSent: 5,
|
||||||
|
userId: "user-123",
|
||||||
|
fullTranscriptUrl: "https://example.com/transcript",
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
id: "msg-1",
|
||||||
|
content: "Hello",
|
||||||
|
role: "user",
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should support keyboard navigation for back button", async () => {
|
||||||
|
render(<SessionViewPage />);
|
||||||
|
|
||||||
|
await screen.findByText("Session Details");
|
||||||
|
|
||||||
|
const backButton = screen.getByRole("button", { name: /return to sessions list/i });
|
||||||
|
|
||||||
|
// Focus and activate with keyboard
|
||||||
|
backButton.focus();
|
||||||
|
expect(backButton).toHaveFocus();
|
||||||
|
|
||||||
|
// Should have proper focus ring
|
||||||
|
expect(backButton.className).toMatch(/focus/i);
|
||||||
|
|
||||||
|
// Test Enter key activation
|
||||||
|
fireEvent.keyDown(backButton, { key: "Enter" });
|
||||||
|
// Navigation behavior would be tested in integration tests
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should support keyboard navigation for external links", async () => {
|
||||||
|
render(<SessionViewPage />);
|
||||||
|
|
||||||
|
await screen.findByText("Session Details");
|
||||||
|
|
||||||
|
const transcriptLink = screen.getByRole("link", { name: /open original transcript in new tab/i });
|
||||||
|
|
||||||
|
// Focus the link
|
||||||
|
transcriptLink.focus();
|
||||||
|
expect(transcriptLink).toHaveFocus();
|
||||||
|
|
||||||
|
// Should have proper focus ring
|
||||||
|
expect(transcriptLink.className).toMatch(/focus/i);
|
||||||
|
|
||||||
|
// Test Enter key activation
|
||||||
|
fireEvent.keyDown(transcriptLink, { key: "Enter" });
|
||||||
|
// Link behavior would open in new tab
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should support tab navigation through session details", async () => {
|
||||||
|
render(<SessionViewPage />);
|
||||||
|
|
||||||
|
await screen.findByText("Session Details");
|
||||||
|
|
||||||
|
// Get all focusable elements
|
||||||
|
const backButton = screen.getByRole("button", { name: /return to sessions list/i });
|
||||||
|
const transcriptLink = screen.getByRole("link", { name: /open original transcript in new tab/i });
|
||||||
|
|
||||||
|
// Test tab order
|
||||||
|
backButton.focus();
|
||||||
|
expect(document.activeElement).toBe(backButton);
|
||||||
|
|
||||||
|
// Tab to next focusable element
|
||||||
|
fireEvent.keyDown(backButton, { key: "Tab" });
|
||||||
|
// Should move to next interactive element
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Chart Component Keyboard Navigation", () => {
|
||||||
|
const mockData = [
|
||||||
|
{ name: "Category A", value: 30, color: "#8884d8" },
|
||||||
|
{ name: "Category B", value: 20, color: "#82ca9d" },
|
||||||
|
{ name: "Category C", value: 50, color: "#ffc658" },
|
||||||
|
];
|
||||||
|
|
||||||
|
it("should support keyboard focus on chart elements", () => {
|
||||||
|
render(
|
||||||
|
<ModernDonutChart
|
||||||
|
data={mockData}
|
||||||
|
title="Test Chart"
|
||||||
|
height={300}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const chart = screen.getByRole("img", { name: /test chart/i });
|
||||||
|
|
||||||
|
// Chart should be focusable
|
||||||
|
chart.focus();
|
||||||
|
expect(chart).toHaveFocus();
|
||||||
|
|
||||||
|
// Should have proper focus styling
|
||||||
|
expect(chart.className).toMatch(/focus/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle keyboard interactions on chart", () => {
|
||||||
|
render(
|
||||||
|
<ModernDonutChart
|
||||||
|
data={mockData}
|
||||||
|
title="Test Chart"
|
||||||
|
height={300}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const chart = screen.getByRole("img", { name: /test chart/i });
|
||||||
|
|
||||||
|
chart.focus();
|
||||||
|
|
||||||
|
// Test keyboard interactions
|
||||||
|
fireEvent.keyDown(chart, { key: "Enter" });
|
||||||
|
fireEvent.keyDown(chart, { key: " " });
|
||||||
|
fireEvent.keyDown(chart, { key: "ArrowLeft" });
|
||||||
|
fireEvent.keyDown(chart, { key: "ArrowRight" });
|
||||||
|
|
||||||
|
// Should not throw errors
|
||||||
|
expect(chart).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should provide keyboard alternative for chart interactions", () => {
|
||||||
|
render(
|
||||||
|
<ModernDonutChart
|
||||||
|
data={mockData}
|
||||||
|
title="Test Chart"
|
||||||
|
height={300}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Chart should have ARIA label for screen readers
|
||||||
|
const chart = screen.getByRole("img");
|
||||||
|
expect(chart).toHaveAttribute("aria-label");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Focus Management", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mockUseSession.mockReturnValue({
|
||||||
|
data: { user: { role: "ADMIN" } },
|
||||||
|
status: "authenticated",
|
||||||
|
});
|
||||||
|
|
||||||
|
(global.fetch as any).mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: () => Promise.resolve({ users: [] }),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should maintain focus after dynamic content changes", async () => {
|
||||||
|
render(<UserManagementPage />);
|
||||||
|
|
||||||
|
await screen.findByText("User Management");
|
||||||
|
|
||||||
|
const emailInput = screen.getByLabelText("Email");
|
||||||
|
const submitButton = screen.getByRole("button", { name: /invite user/i });
|
||||||
|
|
||||||
|
// Focus on input
|
||||||
|
emailInput.focus();
|
||||||
|
expect(document.activeElement).toBe(emailInput);
|
||||||
|
|
||||||
|
// Trigger form submission (which updates the UI)
|
||||||
|
fireEvent.change(emailInput, { target: { value: "test@example.com" } });
|
||||||
|
|
||||||
|
// Mock successful response
|
||||||
|
(global.fetch as any).mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
json: () => Promise.resolve({ message: "User invited successfully" }),
|
||||||
|
}).mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
json: () => Promise.resolve({ users: [] }),
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.click(submitButton);
|
||||||
|
|
||||||
|
// Focus should be managed appropriately after submission
|
||||||
|
// (exact behavior depends on implementation)
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle focus when elements are disabled", async () => {
|
||||||
|
render(<UserManagementPage />);
|
||||||
|
|
||||||
|
await screen.findByText("User Management");
|
||||||
|
|
||||||
|
const submitButton = screen.getByRole("button", { name: /invite user/i });
|
||||||
|
|
||||||
|
// Button should be disabled when form is invalid
|
||||||
|
expect(submitButton).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Should handle focus on disabled elements gracefully
|
||||||
|
submitButton.focus();
|
||||||
|
fireEvent.keyDown(submitButton, { key: "Enter" });
|
||||||
|
|
||||||
|
// Should not cause errors
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should skip over non-interactive elements", async () => {
|
||||||
|
render(<UserManagementPage />);
|
||||||
|
|
||||||
|
await screen.findByText("User Management");
|
||||||
|
|
||||||
|
// Tab navigation should skip over static text and focus only on interactive elements
|
||||||
|
const interactiveElements = [
|
||||||
|
screen.getByLabelText("Email"),
|
||||||
|
screen.getByLabelText("Role"),
|
||||||
|
screen.getByRole("button", { name: /invite user/i }),
|
||||||
|
];
|
||||||
|
|
||||||
|
interactiveElements.forEach((element) => {
|
||||||
|
element.focus();
|
||||||
|
expect(document.activeElement).toBe(element);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Screen Reader Support", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mockUseSession.mockReturnValue({
|
||||||
|
data: { user: { role: "ADMIN" } },
|
||||||
|
status: "authenticated",
|
||||||
|
});
|
||||||
|
|
||||||
|
(global.fetch as any).mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: () => Promise.resolve({ users: [] }),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should announce form validation errors", async () => {
|
||||||
|
render(<UserManagementPage />);
|
||||||
|
|
||||||
|
await screen.findByText("User Management");
|
||||||
|
|
||||||
|
const emailInput = screen.getByLabelText("Email") as HTMLInputElement;
|
||||||
|
const submitButton = screen.getByRole("button", { name: /invite user/i });
|
||||||
|
|
||||||
|
// Submit invalid form
|
||||||
|
fireEvent.click(submitButton);
|
||||||
|
|
||||||
|
// HTML5 validation should be triggered
|
||||||
|
expect(emailInput.validity.valid).toBeFalsy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should announce loading states", async () => {
|
||||||
|
// Test loading state announcement
|
||||||
|
mockUseSession.mockReturnValue({
|
||||||
|
data: null,
|
||||||
|
status: "loading",
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<UserManagementPage />);
|
||||||
|
|
||||||
|
const loadingText = screen.getByText("Loading users...");
|
||||||
|
expect(loadingText).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should announce success and error messages", async () => {
|
||||||
|
render(<UserManagementPage />);
|
||||||
|
|
||||||
|
await screen.findByText("User Management");
|
||||||
|
|
||||||
|
const emailInput = screen.getByLabelText("Email");
|
||||||
|
const submitButton = screen.getByRole("button", { name: /invite user/i });
|
||||||
|
|
||||||
|
// Fill form
|
||||||
|
fireEvent.change(emailInput, { target: { value: "test@example.com" } });
|
||||||
|
|
||||||
|
// Mock error response
|
||||||
|
(global.fetch as any).mockResolvedValueOnce({
|
||||||
|
ok: false,
|
||||||
|
json: () => Promise.resolve({ message: "Email already exists" }),
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.click(submitButton);
|
||||||
|
|
||||||
|
// Error message should be announced
|
||||||
|
await screen.findByText(/failed to invite user/i);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("High Contrast Mode Support", () => {
|
||||||
|
it("should maintain keyboard navigation in high contrast mode", async () => {
|
||||||
|
// Mock high contrast media query
|
||||||
|
Object.defineProperty(window, "matchMedia", {
|
||||||
|
writable: true,
|
||||||
|
value: vi.fn().mockImplementation(query => ({
|
||||||
|
matches: query === "(prefers-contrast: high)",
|
||||||
|
media: query,
|
||||||
|
onchange: null,
|
||||||
|
addListener: vi.fn(),
|
||||||
|
removeListener: vi.fn(),
|
||||||
|
addEventListener: vi.fn(),
|
||||||
|
removeEventListener: vi.fn(),
|
||||||
|
dispatchEvent: vi.fn(),
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
|
||||||
|
mockUseSession.mockReturnValue({
|
||||||
|
data: { user: { role: "ADMIN" } },
|
||||||
|
status: "authenticated",
|
||||||
|
});
|
||||||
|
|
||||||
|
(global.fetch as any).mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: () => Promise.resolve({ users: [] }),
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<UserManagementPage />);
|
||||||
|
|
||||||
|
await screen.findByText("User Management");
|
||||||
|
|
||||||
|
const emailInput = screen.getByLabelText("Email");
|
||||||
|
|
||||||
|
// Focus should still work in high contrast mode
|
||||||
|
emailInput.focus();
|
||||||
|
expect(emailInput).toHaveFocus();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
368
tests/unit/user-management.test.tsx
Normal file
368
tests/unit/user-management.test.tsx
Normal file
@ -0,0 +1,368 @@
|
|||||||
|
/**
|
||||||
|
* @vitest-environment jsdom
|
||||||
|
*/
|
||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||||
|
import { useSession } from "next-auth/react";
|
||||||
|
import UserManagementPage from "@/app/dashboard/users/page";
|
||||||
|
|
||||||
|
// Mock next-auth
|
||||||
|
vi.mock("next-auth/react");
|
||||||
|
const mockUseSession = vi.mocked(useSession);
|
||||||
|
|
||||||
|
// Mock fetch
|
||||||
|
const mockFetch = vi.fn();
|
||||||
|
global.fetch = mockFetch;
|
||||||
|
|
||||||
|
// Mock user data
|
||||||
|
const mockUsers = [
|
||||||
|
{ id: "1", email: "admin@example.com", role: "ADMIN" },
|
||||||
|
{ id: "2", email: "user@example.com", role: "USER" },
|
||||||
|
{ id: "3", email: "auditor@example.com", role: "AUDITOR" },
|
||||||
|
];
|
||||||
|
|
||||||
|
describe("UserManagementPage", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
mockFetch.mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: () => Promise.resolve({ users: mockUsers }),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Access Control", () => {
|
||||||
|
it("should deny access for non-admin users", async () => {
|
||||||
|
mockUseSession.mockReturnValue({
|
||||||
|
data: { user: { role: "USER" } },
|
||||||
|
status: "authenticated",
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<UserManagementPage />);
|
||||||
|
|
||||||
|
await screen.findByText("Access Denied");
|
||||||
|
expect(
|
||||||
|
screen.getByText("You don't have permission to view user management.")
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should allow access for admin users", async () => {
|
||||||
|
mockUseSession.mockReturnValue({
|
||||||
|
data: { user: { role: "ADMIN" } },
|
||||||
|
status: "authenticated",
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<UserManagementPage />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("User Management")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should show loading state while checking authentication", () => {
|
||||||
|
mockUseSession.mockReturnValue({
|
||||||
|
data: null,
|
||||||
|
status: "loading",
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<UserManagementPage />);
|
||||||
|
|
||||||
|
expect(screen.getByText("Loading users...")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("User List Display", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mockUseSession.mockReturnValue({
|
||||||
|
data: { user: { role: "ADMIN" } },
|
||||||
|
status: "authenticated",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should display all users with correct information", async () => {
|
||||||
|
render(<UserManagementPage />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("admin@example.com")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("user@example.com")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("auditor@example.com")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should display role badges with correct variants", async () => {
|
||||||
|
render(<UserManagementPage />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
// Check for role badges
|
||||||
|
const adminBadges = screen.getAllByText("ADMIN");
|
||||||
|
const userBadges = screen.getAllByText("USER");
|
||||||
|
const auditorBadges = screen.getAllByText("AUDITOR");
|
||||||
|
|
||||||
|
expect(adminBadges.length).toBeGreaterThan(0);
|
||||||
|
expect(userBadges.length).toBeGreaterThan(0);
|
||||||
|
expect(auditorBadges.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should show user count in header", async () => {
|
||||||
|
render(<UserManagementPage />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("Current Users (3)")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle empty user list", async () => {
|
||||||
|
mockFetch.mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: () => Promise.resolve({ users: [] }),
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<UserManagementPage />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("No users found")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("User Invitation Form", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mockUseSession.mockReturnValue({
|
||||||
|
data: { user: { role: "ADMIN" } },
|
||||||
|
status: "authenticated",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should render invitation form with all fields", async () => {
|
||||||
|
render(<UserManagementPage />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByLabelText("Email")).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("combobox")).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("button", { name: /invite user/i })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle successful user invitation", async () => {
|
||||||
|
const mockInviteResponse = {
|
||||||
|
ok: true,
|
||||||
|
json: () => Promise.resolve({ message: "User invited successfully" }),
|
||||||
|
};
|
||||||
|
|
||||||
|
mockFetch
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
json: () => Promise.resolve({ users: mockUsers }),
|
||||||
|
})
|
||||||
|
.mockResolvedValueOnce(mockInviteResponse)
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
json: () => Promise.resolve({ users: [...mockUsers, { id: "4", email: "new@example.com", role: "USER" }] }),
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<UserManagementPage />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
const emailInput = screen.getByLabelText("Email");
|
||||||
|
const submitButton = screen.getByRole("button", { name: /invite user/i });
|
||||||
|
|
||||||
|
fireEvent.change(emailInput, { target: { value: "new@example.com" } });
|
||||||
|
fireEvent.click(submitButton);
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("User invited successfully!")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle invitation errors", async () => {
|
||||||
|
const mockErrorResponse = {
|
||||||
|
ok: false,
|
||||||
|
json: () => Promise.resolve({ message: "Email already exists" }),
|
||||||
|
};
|
||||||
|
|
||||||
|
mockFetch
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
json: () => Promise.resolve({ users: mockUsers }),
|
||||||
|
})
|
||||||
|
.mockResolvedValueOnce(mockErrorResponse);
|
||||||
|
|
||||||
|
render(<UserManagementPage />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
const emailInput = screen.getByLabelText("Email");
|
||||||
|
const submitButton = screen.getByRole("button", { name: /invite user/i });
|
||||||
|
|
||||||
|
fireEvent.change(emailInput, { target: { value: "existing@example.com" } });
|
||||||
|
fireEvent.click(submitButton);
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText(/Failed to invite user: Email already exists/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should clear form after successful invitation", async () => {
|
||||||
|
const mockInviteResponse = {
|
||||||
|
ok: true,
|
||||||
|
json: () => Promise.resolve({ message: "User invited successfully" }),
|
||||||
|
};
|
||||||
|
|
||||||
|
mockFetch
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
json: () => Promise.resolve({ users: mockUsers }),
|
||||||
|
})
|
||||||
|
.mockResolvedValueOnce(mockInviteResponse)
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
json: () => Promise.resolve({ users: mockUsers }),
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<UserManagementPage />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
const emailInput = screen.getByLabelText("Email") as HTMLInputElement;
|
||||||
|
const submitButton = screen.getByRole("button", { name: /invite user/i });
|
||||||
|
|
||||||
|
fireEvent.change(emailInput, { target: { value: "new@example.com" } });
|
||||||
|
fireEvent.click(submitButton);
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
const emailInput = screen.getByLabelText("Email") as HTMLInputElement;
|
||||||
|
expect(emailInput.value).toBe("");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Form Validation", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mockUseSession.mockReturnValue({
|
||||||
|
data: { user: { role: "ADMIN" } },
|
||||||
|
status: "authenticated",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should require email field", async () => {
|
||||||
|
render(<UserManagementPage />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
const submitButton = screen.getByRole("button", { name: /invite user/i });
|
||||||
|
fireEvent.click(submitButton);
|
||||||
|
|
||||||
|
// HTML5 validation should prevent submission
|
||||||
|
const emailInput = screen.getByLabelText("Email") as HTMLInputElement;
|
||||||
|
expect(emailInput.validity.valid).toBeFalsy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should validate email format", async () => {
|
||||||
|
render(<UserManagementPage />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
const emailInput = screen.getByLabelText("Email") as HTMLInputElement;
|
||||||
|
|
||||||
|
fireEvent.change(emailInput, { target: { value: "invalid-email" } });
|
||||||
|
fireEvent.blur(emailInput);
|
||||||
|
|
||||||
|
expect(emailInput.validity.valid).toBeFalsy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Accessibility", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mockUseSession.mockReturnValue({
|
||||||
|
data: { user: { role: "ADMIN" } },
|
||||||
|
status: "authenticated",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should have proper ARIA labels", async () => {
|
||||||
|
render(<UserManagementPage />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByLabelText("Email")).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("combobox")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should have proper table structure", async () => {
|
||||||
|
render(<UserManagementPage />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
const table = screen.getByRole("table");
|
||||||
|
expect(table).toBeInTheDocument();
|
||||||
|
|
||||||
|
const columnHeaders = screen.getAllByRole("columnheader");
|
||||||
|
expect(columnHeaders).toHaveLength(3);
|
||||||
|
expect(columnHeaders[0]).toHaveTextContent("Email");
|
||||||
|
expect(columnHeaders[1]).toHaveTextContent("Role");
|
||||||
|
expect(columnHeaders[2]).toHaveTextContent("Actions");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should have proper form structure", async () => {
|
||||||
|
render(<UserManagementPage />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
const form = screen.getByRole("form");
|
||||||
|
expect(form).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Error Handling", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mockUseSession.mockReturnValue({
|
||||||
|
data: { user: { role: "ADMIN" } },
|
||||||
|
status: "authenticated",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle network errors when fetching users", async () => {
|
||||||
|
mockFetch.mockRejectedValue(new Error("Network error"));
|
||||||
|
|
||||||
|
// Mock console.error to avoid noise in tests
|
||||||
|
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
|
||||||
|
render(<UserManagementPage />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("Failed to load users.")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
consoleSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle network errors when inviting users", async () => {
|
||||||
|
mockFetch
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
json: () => Promise.resolve({ users: mockUsers }),
|
||||||
|
})
|
||||||
|
.mockRejectedValueOnce(new Error("Network error"));
|
||||||
|
|
||||||
|
// Mock console.error to avoid noise in tests
|
||||||
|
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
|
||||||
|
render(<UserManagementPage />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
const emailInput = screen.getByLabelText("Email");
|
||||||
|
const submitButton = screen.getByRole("button", { name: /invite user/i });
|
||||||
|
|
||||||
|
fireEvent.change(emailInput, { target: { value: "test@example.com" } });
|
||||||
|
fireEvent.click(submitButton);
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("Failed to invite user. Please try again.")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
consoleSpy.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -1,4 +1,4 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect } from "vitest";
|
||||||
import {
|
import {
|
||||||
registerSchema,
|
registerSchema,
|
||||||
loginSchema,
|
loginSchema,
|
||||||
@ -9,30 +9,30 @@ import {
|
|||||||
userUpdateSchema,
|
userUpdateSchema,
|
||||||
metricsQuerySchema,
|
metricsQuerySchema,
|
||||||
validateInput,
|
validateInput,
|
||||||
} from '../../lib/validation';
|
} from "../../lib/validation";
|
||||||
|
|
||||||
describe('Validation Schemas', () => {
|
describe("Validation Schemas", () => {
|
||||||
// Helper for password validation
|
// Helper for password validation
|
||||||
const validPassword = 'Password123!';
|
const validPassword = "Password123!";
|
||||||
const invalidPasswordShort = 'Pass1!';
|
const invalidPasswordShort = "Pass1!";
|
||||||
const invalidPasswordNoLower = 'PASSWORD123!';
|
const invalidPasswordNoLower = "PASSWORD123!";
|
||||||
const invalidPasswordNoUpper = 'password123!';
|
const invalidPasswordNoUpper = "password123!";
|
||||||
const invalidPasswordNoNumber = 'Password!!';
|
const invalidPasswordNoNumber = "Password!!";
|
||||||
const invalidPasswordNoSpecial = 'Password123';
|
const invalidPasswordNoSpecial = "Password123";
|
||||||
|
|
||||||
// Helper for email validation
|
// Helper for email validation
|
||||||
const validEmail = 'test@example.com';
|
const validEmail = "test@example.com";
|
||||||
const invalidEmailFormat = 'test@example';
|
const invalidEmailFormat = "test@example";
|
||||||
const invalidEmailTooLong = 'a'.repeat(250) + '@example.com'; // 250 + 11 = 261 chars
|
const invalidEmailTooLong = "a".repeat(250) + "@example.com"; // 250 + 11 = 261 chars
|
||||||
|
|
||||||
// Helper for company name validation
|
// Helper for company name validation
|
||||||
const validCompanyName = 'My Company Inc.';
|
const validCompanyName = "My Company Inc.";
|
||||||
const invalidCompanyNameEmpty = '';
|
const invalidCompanyNameEmpty = "";
|
||||||
const invalidCompanyNameTooLong = 'A'.repeat(101);
|
const invalidCompanyNameTooLong = "A".repeat(101);
|
||||||
const invalidCompanyNameChars = 'My Company #$%';
|
const invalidCompanyNameChars = "My Company #$%";
|
||||||
|
|
||||||
describe('registerSchema', () => {
|
describe("registerSchema", () => {
|
||||||
it('should validate a valid registration object', () => {
|
it("should validate a valid registration object", () => {
|
||||||
const data = {
|
const data = {
|
||||||
email: validEmail,
|
email: validEmail,
|
||||||
password: validPassword,
|
password: validPassword,
|
||||||
@ -41,7 +41,7 @@ describe('Validation Schemas', () => {
|
|||||||
expect(registerSchema.safeParse(data).success).toBe(true);
|
expect(registerSchema.safeParse(data).success).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should invalidate an invalid email', () => {
|
it("should invalidate an invalid email", () => {
|
||||||
const data = {
|
const data = {
|
||||||
email: invalidEmailFormat,
|
email: invalidEmailFormat,
|
||||||
password: validPassword,
|
password: validPassword,
|
||||||
@ -50,7 +50,7 @@ describe('Validation Schemas', () => {
|
|||||||
expect(registerSchema.safeParse(data).success).toBe(false);
|
expect(registerSchema.safeParse(data).success).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should invalidate an invalid password', () => {
|
it("should invalidate an invalid password", () => {
|
||||||
const data = {
|
const data = {
|
||||||
email: validEmail,
|
email: validEmail,
|
||||||
password: invalidPasswordShort,
|
password: invalidPasswordShort,
|
||||||
@ -59,7 +59,7 @@ describe('Validation Schemas', () => {
|
|||||||
expect(registerSchema.safeParse(data).success).toBe(false);
|
expect(registerSchema.safeParse(data).success).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should invalidate an invalid company name', () => {
|
it("should invalidate an invalid company name", () => {
|
||||||
const data = {
|
const data = {
|
||||||
email: validEmail,
|
email: validEmail,
|
||||||
password: validPassword,
|
password: validPassword,
|
||||||
@ -69,8 +69,8 @@ describe('Validation Schemas', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('loginSchema', () => {
|
describe("loginSchema", () => {
|
||||||
it('should validate a valid login object', () => {
|
it("should validate a valid login object", () => {
|
||||||
const data = {
|
const data = {
|
||||||
email: validEmail,
|
email: validEmail,
|
||||||
password: validPassword,
|
password: validPassword,
|
||||||
@ -78,7 +78,7 @@ describe('Validation Schemas', () => {
|
|||||||
expect(loginSchema.safeParse(data).success).toBe(true);
|
expect(loginSchema.safeParse(data).success).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should invalidate an invalid email', () => {
|
it("should invalidate an invalid email", () => {
|
||||||
const data = {
|
const data = {
|
||||||
email: invalidEmailFormat,
|
email: invalidEmailFormat,
|
||||||
password: validPassword,
|
password: validPassword,
|
||||||
@ -86,208 +86,208 @@ describe('Validation Schemas', () => {
|
|||||||
expect(loginSchema.safeParse(data).success).toBe(false);
|
expect(loginSchema.safeParse(data).success).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should invalidate an empty password', () => {
|
it("should invalidate an empty password", () => {
|
||||||
const data = {
|
const data = {
|
||||||
email: validEmail,
|
email: validEmail,
|
||||||
password: '',
|
password: "",
|
||||||
};
|
};
|
||||||
expect(loginSchema.safeParse(data).success).toBe(false);
|
expect(loginSchema.safeParse(data).success).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('forgotPasswordSchema', () => {
|
describe("forgotPasswordSchema", () => {
|
||||||
it('should validate a valid email', () => {
|
it("should validate a valid email", () => {
|
||||||
const data = { email: validEmail };
|
const data = { email: validEmail };
|
||||||
expect(forgotPasswordSchema.safeParse(data).success).toBe(true);
|
expect(forgotPasswordSchema.safeParse(data).success).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should invalidate an invalid email', () => {
|
it("should invalidate an invalid email", () => {
|
||||||
const data = { email: invalidEmailFormat };
|
const data = { email: invalidEmailFormat };
|
||||||
expect(forgotPasswordSchema.safeParse(data).success).toBe(false);
|
expect(forgotPasswordSchema.safeParse(data).success).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('resetPasswordSchema', () => {
|
describe("resetPasswordSchema", () => {
|
||||||
it('should validate a valid reset password object', () => {
|
it("should validate a valid reset password object", () => {
|
||||||
const data = {
|
const data = {
|
||||||
token: 'some-valid-token',
|
token: "some-valid-token",
|
||||||
password: validPassword,
|
password: validPassword,
|
||||||
};
|
};
|
||||||
expect(resetPasswordSchema.safeParse(data).success).toBe(true);
|
expect(resetPasswordSchema.safeParse(data).success).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should invalidate an empty token', () => {
|
it("should invalidate an empty token", () => {
|
||||||
const data = {
|
const data = {
|
||||||
token: '',
|
token: "",
|
||||||
password: validPassword,
|
password: validPassword,
|
||||||
};
|
};
|
||||||
expect(resetPasswordSchema.safeParse(data).success).toBe(false);
|
expect(resetPasswordSchema.safeParse(data).success).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should invalidate an invalid password', () => {
|
it("should invalidate an invalid password", () => {
|
||||||
const data = {
|
const data = {
|
||||||
token: 'some-valid-token',
|
token: "some-valid-token",
|
||||||
password: invalidPasswordShort,
|
password: invalidPasswordShort,
|
||||||
};
|
};
|
||||||
expect(resetPasswordSchema.safeParse(data).success).toBe(false);
|
expect(resetPasswordSchema.safeParse(data).success).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('sessionFilterSchema', () => {
|
describe("sessionFilterSchema", () => {
|
||||||
it('should validate a valid session filter object', () => {
|
it("should validate a valid session filter object", () => {
|
||||||
const data = {
|
const data = {
|
||||||
search: 'query',
|
search: "query",
|
||||||
sentiment: 'POSITIVE',
|
sentiment: "POSITIVE",
|
||||||
category: 'SCHEDULE_HOURS',
|
category: "SCHEDULE_HOURS",
|
||||||
startDate: '2023-01-01T00:00:00Z',
|
startDate: "2023-01-01T00:00:00Z",
|
||||||
endDate: '2023-01-31T23:59:59Z',
|
endDate: "2023-01-31T23:59:59Z",
|
||||||
page: 1,
|
page: 1,
|
||||||
limit: 20,
|
limit: 20,
|
||||||
};
|
};
|
||||||
expect(sessionFilterSchema.safeParse(data).success).toBe(true);
|
expect(sessionFilterSchema.safeParse(data).success).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should validate with only optional fields', () => {
|
it("should validate with only optional fields", () => {
|
||||||
const data = {};
|
const data = {};
|
||||||
expect(sessionFilterSchema.safeParse(data).success).toBe(true);
|
expect(sessionFilterSchema.safeParse(data).success).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should invalidate an invalid sentiment', () => {
|
it("should invalidate an invalid sentiment", () => {
|
||||||
const data = { sentiment: 'INVALID' };
|
const data = { sentiment: "INVALID" };
|
||||||
expect(sessionFilterSchema.safeParse(data).success).toBe(false);
|
expect(sessionFilterSchema.safeParse(data).success).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should invalidate an invalid category', () => {
|
it("should invalidate an invalid category", () => {
|
||||||
const data = { category: 'INVALID_CATEGORY' };
|
const data = { category: "INVALID_CATEGORY" };
|
||||||
expect(sessionFilterSchema.safeParse(data).success).toBe(false);
|
expect(sessionFilterSchema.safeParse(data).success).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should invalidate an invalid date format', () => {
|
it("should invalidate an invalid date format", () => {
|
||||||
const data = { startDate: '2023-01-01' }; // Missing time
|
const data = { startDate: "2023-01-01" }; // Missing time
|
||||||
expect(sessionFilterSchema.safeParse(data).success).toBe(false);
|
expect(sessionFilterSchema.safeParse(data).success).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should invalidate page less than 1', () => {
|
it("should invalidate page less than 1", () => {
|
||||||
const data = { page: 0 };
|
const data = { page: 0 };
|
||||||
expect(sessionFilterSchema.safeParse(data).success).toBe(false);
|
expect(sessionFilterSchema.safeParse(data).success).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should invalidate limit greater than 100', () => {
|
it("should invalidate limit greater than 100", () => {
|
||||||
const data = { limit: 101 };
|
const data = { limit: 101 };
|
||||||
expect(sessionFilterSchema.safeParse(data).success).toBe(false);
|
expect(sessionFilterSchema.safeParse(data).success).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('companySettingsSchema', () => {
|
describe("companySettingsSchema", () => {
|
||||||
it('should validate a valid company settings object', () => {
|
it("should validate a valid company settings object", () => {
|
||||||
const data = {
|
const data = {
|
||||||
name: validCompanyName,
|
name: validCompanyName,
|
||||||
csvUrl: 'http://example.com/data.csv',
|
csvUrl: "http://example.com/data.csv",
|
||||||
csvUsername: 'user',
|
csvUsername: "user",
|
||||||
csvPassword: 'password',
|
csvPassword: "password",
|
||||||
sentimentAlert: 0.5,
|
sentimentAlert: 0.5,
|
||||||
dashboardOpts: { theme: 'dark' },
|
dashboardOpts: { theme: "dark" },
|
||||||
};
|
};
|
||||||
expect(companySettingsSchema.safeParse(data).success).toBe(true);
|
expect(companySettingsSchema.safeParse(data).success).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should invalidate an invalid CSV URL', () => {
|
it("should invalidate an invalid CSV URL", () => {
|
||||||
const data = {
|
const data = {
|
||||||
name: validCompanyName,
|
name: validCompanyName,
|
||||||
csvUrl: 'invalid-url',
|
csvUrl: "invalid-url",
|
||||||
};
|
};
|
||||||
expect(companySettingsSchema.safeParse(data).success).toBe(false);
|
expect(companySettingsSchema.safeParse(data).success).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should invalidate an invalid company name', () => {
|
it("should invalidate an invalid company name", () => {
|
||||||
const data = {
|
const data = {
|
||||||
name: invalidCompanyNameEmpty,
|
name: invalidCompanyNameEmpty,
|
||||||
csvUrl: 'http://example.com/data.csv',
|
csvUrl: "http://example.com/data.csv",
|
||||||
};
|
};
|
||||||
expect(companySettingsSchema.safeParse(data).success).toBe(false);
|
expect(companySettingsSchema.safeParse(data).success).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should invalidate sentimentAlert out of range', () => {
|
it("should invalidate sentimentAlert out of range", () => {
|
||||||
const data = {
|
const data = {
|
||||||
name: validCompanyName,
|
name: validCompanyName,
|
||||||
csvUrl: 'http://example.com/data.csv',
|
csvUrl: "http://example.com/data.csv",
|
||||||
sentimentAlert: 1.1,
|
sentimentAlert: 1.1,
|
||||||
};
|
};
|
||||||
expect(companySettingsSchema.safeParse(data).success).toBe(false);
|
expect(companySettingsSchema.safeParse(data).success).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('userUpdateSchema', () => {
|
describe("userUpdateSchema", () => {
|
||||||
it('should validate a valid user update object with all fields', () => {
|
it("should validate a valid user update object with all fields", () => {
|
||||||
const data = {
|
const data = {
|
||||||
email: validEmail,
|
email: validEmail,
|
||||||
role: 'ADMIN',
|
role: "ADMIN",
|
||||||
password: validPassword,
|
password: validPassword,
|
||||||
};
|
};
|
||||||
expect(userUpdateSchema.safeParse(data).success).toBe(true);
|
expect(userUpdateSchema.safeParse(data).success).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should validate a valid user update object with only email', () => {
|
it("should validate a valid user update object with only email", () => {
|
||||||
const data = { email: validEmail };
|
const data = { email: validEmail };
|
||||||
expect(userUpdateSchema.safeParse(data).success).toBe(true);
|
expect(userUpdateSchema.safeParse(data).success).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should validate a valid user update object with only role', () => {
|
it("should validate a valid user update object with only role", () => {
|
||||||
const data = { role: 'USER' };
|
const data = { role: "USER" };
|
||||||
expect(userUpdateSchema.safeParse(data).success).toBe(true);
|
expect(userUpdateSchema.safeParse(data).success).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should validate a valid user update object with only password', () => {
|
it("should validate a valid user update object with only password", () => {
|
||||||
const data = { password: validPassword };
|
const data = { password: validPassword };
|
||||||
expect(userUpdateSchema.safeParse(data).success).toBe(true);
|
expect(userUpdateSchema.safeParse(data).success).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should invalidate an invalid email', () => {
|
it("should invalidate an invalid email", () => {
|
||||||
const data = { email: invalidEmailFormat };
|
const data = { email: invalidEmailFormat };
|
||||||
expect(userUpdateSchema.safeParse(data).success).toBe(false);
|
expect(userUpdateSchema.safeParse(data).success).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should invalidate an invalid role', () => {
|
it("should invalidate an invalid role", () => {
|
||||||
const data = { role: 'SUPERUSER' };
|
const data = { role: "SUPERUSER" };
|
||||||
expect(userUpdateSchema.safeParse(data).success).toBe(false);
|
expect(userUpdateSchema.safeParse(data).success).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should invalidate an invalid password', () => {
|
it("should invalidate an invalid password", () => {
|
||||||
const data = { password: invalidPasswordShort };
|
const data = { password: invalidPasswordShort };
|
||||||
expect(userUpdateSchema.safeParse(data).success).toBe(false);
|
expect(userUpdateSchema.safeParse(data).success).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('metricsQuerySchema', () => {
|
describe("metricsQuerySchema", () => {
|
||||||
it('should validate a valid metrics query object', () => {
|
it("should validate a valid metrics query object", () => {
|
||||||
const data = {
|
const data = {
|
||||||
startDate: '2023-01-01T00:00:00Z',
|
startDate: "2023-01-01T00:00:00Z",
|
||||||
endDate: '2023-01-31T23:59:59Z',
|
endDate: "2023-01-31T23:59:59Z",
|
||||||
companyId: 'a1b2c3d4-e5f6-7890-1234-567890abcdef',
|
companyId: "a1b2c3d4-e5f6-7890-1234-567890abcdef",
|
||||||
};
|
};
|
||||||
expect(metricsQuerySchema.safeParse(data).success).toBe(true);
|
expect(metricsQuerySchema.safeParse(data).success).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should validate with only optional fields', () => {
|
it("should validate with only optional fields", () => {
|
||||||
const data = {};
|
const data = {};
|
||||||
expect(metricsQuerySchema.safeParse(data).success).toBe(true);
|
expect(metricsQuerySchema.safeParse(data).success).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should invalidate an invalid date format', () => {
|
it("should invalidate an invalid date format", () => {
|
||||||
const data = { startDate: '2023-01-01' };
|
const data = { startDate: "2023-01-01" };
|
||||||
expect(metricsQuerySchema.safeParse(data).success).toBe(false);
|
expect(metricsQuerySchema.safeParse(data).success).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should invalidate an invalid companyId format', () => {
|
it("should invalidate an invalid companyId format", () => {
|
||||||
const data = { companyId: 'invalid-uuid' };
|
const data = { companyId: "invalid-uuid" };
|
||||||
expect(metricsQuerySchema.safeParse(data).success).toBe(false);
|
expect(metricsQuerySchema.safeParse(data).success).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('validateInput', () => {
|
describe("validateInput", () => {
|
||||||
const testSchema = registerSchema; // Using registerSchema for validateInput tests
|
const testSchema = registerSchema; // Using registerSchema for validateInput tests
|
||||||
|
|
||||||
it('should return success true and data for valid input', () => {
|
it("should return success true and data for valid input", () => {
|
||||||
const data = {
|
const data = {
|
||||||
email: validEmail,
|
email: validEmail,
|
||||||
password: validPassword,
|
password: validPassword,
|
||||||
@ -298,7 +298,7 @@ describe('Validation Schemas', () => {
|
|||||||
expect((result as any).data).toEqual(data);
|
expect((result as any).data).toEqual(data);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return success false and errors for invalid input', () => {
|
it("should return success false and errors for invalid input", () => {
|
||||||
const data = {
|
const data = {
|
||||||
email: invalidEmailFormat,
|
email: invalidEmailFormat,
|
||||||
password: invalidPasswordShort,
|
password: invalidPasswordShort,
|
||||||
@ -306,20 +306,24 @@ describe('Validation Schemas', () => {
|
|||||||
};
|
};
|
||||||
const result = validateInput(testSchema, data);
|
const result = validateInput(testSchema, data);
|
||||||
expect(result.success).toBe(false);
|
expect(result.success).toBe(false);
|
||||||
expect((result as any).errors).toEqual(expect.arrayContaining([
|
expect((result as any).errors).toEqual(
|
||||||
'email: Invalid email format',
|
expect.arrayContaining([
|
||||||
'password: Password must be at least 12 characters long',
|
"email: Invalid email format",
|
||||||
'company: Company name is required',
|
"password: Password must be at least 12 characters long",
|
||||||
]));
|
"company: Company name is required",
|
||||||
|
])
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should handle non-ZodError errors gracefully', () => {
|
it("should handle non-ZodError errors gracefully", () => {
|
||||||
const mockSchema = {
|
const mockSchema = {
|
||||||
parse: () => { throw new Error('Some unexpected error'); }
|
parse: () => {
|
||||||
|
throw new Error("Some unexpected error");
|
||||||
|
},
|
||||||
} as any;
|
} as any;
|
||||||
const result = validateInput(mockSchema, {});
|
const result = validateInput(mockSchema, {});
|
||||||
expect(result.success).toBe(false);
|
expect(result.success).toBe(false);
|
||||||
expect((result as any).errors).toEqual(['Invalid input']);
|
expect((result as any).errors).toEqual(["Invalid input"]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
395
tests/visual/theme-switching.spec.ts
Normal file
395
tests/visual/theme-switching.spec.ts
Normal file
@ -0,0 +1,395 @@
|
|||||||
|
import { test, expect } from "@playwright/test";
|
||||||
|
|
||||||
|
test.describe("Theme Switching Visual Tests", () => {
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
// Mock authentication
|
||||||
|
await page.route("**/api/auth/session", async (route) => {
|
||||||
|
const json = {
|
||||||
|
user: {
|
||||||
|
id: "admin-user-id",
|
||||||
|
email: "admin@example.com",
|
||||||
|
role: "ADMIN",
|
||||||
|
},
|
||||||
|
expires: new Date(Date.now() + 2 * 60 * 60 * 1000).toISOString(),
|
||||||
|
};
|
||||||
|
await route.fulfill({ json });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mock users API
|
||||||
|
await page.route("**/api/dashboard/users", async (route) => {
|
||||||
|
if (route.request().method() === "GET") {
|
||||||
|
const json = {
|
||||||
|
users: [
|
||||||
|
{ id: "1", email: "admin@example.com", role: "ADMIN" },
|
||||||
|
{ id: "2", email: "user@example.com", role: "USER" },
|
||||||
|
{ id: "3", email: "auditor@example.com", role: "AUDITOR" },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
await route.fulfill({ json });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("User Management page should render correctly in light theme", async ({ page }) => {
|
||||||
|
await page.goto("/dashboard/users");
|
||||||
|
|
||||||
|
// Wait for content to load
|
||||||
|
await page.waitForSelector('[data-testid="user-management-page"]', { timeout: 10000 });
|
||||||
|
|
||||||
|
// Ensure light theme is active
|
||||||
|
await page.evaluate(() => {
|
||||||
|
document.documentElement.classList.remove("dark");
|
||||||
|
document.documentElement.classList.add("light");
|
||||||
|
});
|
||||||
|
|
||||||
|
// Wait for theme change to apply
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
|
||||||
|
// Take screenshot of the full page
|
||||||
|
await expect(page).toHaveScreenshot("user-management-light-theme.png", {
|
||||||
|
fullPage: true,
|
||||||
|
animations: "disabled",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("User Management page should render correctly in dark theme", async ({ page }) => {
|
||||||
|
await page.goto("/dashboard/users");
|
||||||
|
|
||||||
|
// Wait for content to load
|
||||||
|
await page.waitForSelector('[data-testid="user-management-page"]', { timeout: 10000 });
|
||||||
|
|
||||||
|
// Enable dark theme
|
||||||
|
await page.evaluate(() => {
|
||||||
|
document.documentElement.classList.remove("light");
|
||||||
|
document.documentElement.classList.add("dark");
|
||||||
|
});
|
||||||
|
|
||||||
|
// Wait for theme change to apply
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
|
||||||
|
// Take screenshot of the full page
|
||||||
|
await expect(page).toHaveScreenshot("user-management-dark-theme.png", {
|
||||||
|
fullPage: true,
|
||||||
|
animations: "disabled",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Theme toggle should work correctly", async ({ page }) => {
|
||||||
|
await page.goto("/dashboard/users");
|
||||||
|
|
||||||
|
// Wait for content to load
|
||||||
|
await page.waitForSelector('[data-testid="user-management-page"]', { timeout: 10000 });
|
||||||
|
|
||||||
|
// Find theme toggle button (assuming it exists in the layout)
|
||||||
|
const themeToggle = page.locator('[data-testid="theme-toggle"]').first();
|
||||||
|
|
||||||
|
if (await themeToggle.count() > 0) {
|
||||||
|
// Start with light theme
|
||||||
|
await page.evaluate(() => {
|
||||||
|
document.documentElement.classList.remove("dark");
|
||||||
|
document.documentElement.classList.add("light");
|
||||||
|
});
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
|
||||||
|
// Take screenshot before toggle
|
||||||
|
await expect(page.locator("main")).toHaveScreenshot("before-theme-toggle.png", {
|
||||||
|
animations: "disabled",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Toggle to dark theme
|
||||||
|
await themeToggle.click();
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
|
||||||
|
// Take screenshot after toggle
|
||||||
|
await expect(page.locator("main")).toHaveScreenshot("after-theme-toggle.png", {
|
||||||
|
animations: "disabled",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Form elements should have proper styling in both themes", async ({ page }) => {
|
||||||
|
await page.goto("/dashboard/users");
|
||||||
|
await page.waitForSelector('[data-testid="user-management-page"]', { timeout: 10000 });
|
||||||
|
|
||||||
|
// Test light theme form styling
|
||||||
|
await page.evaluate(() => {
|
||||||
|
document.documentElement.classList.remove("dark");
|
||||||
|
document.documentElement.classList.add("light");
|
||||||
|
});
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
|
||||||
|
const formSection = page.locator('[data-testid="invite-form"]').first();
|
||||||
|
if (await formSection.count() > 0) {
|
||||||
|
await expect(formSection).toHaveScreenshot("form-light-theme.png", {
|
||||||
|
animations: "disabled",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test dark theme form styling
|
||||||
|
await page.evaluate(() => {
|
||||||
|
document.documentElement.classList.remove("light");
|
||||||
|
document.documentElement.classList.add("dark");
|
||||||
|
});
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
|
||||||
|
if (await formSection.count() > 0) {
|
||||||
|
await expect(formSection).toHaveScreenshot("form-dark-theme.png", {
|
||||||
|
animations: "disabled",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Table should render correctly in both themes", async ({ page }) => {
|
||||||
|
await page.goto("/dashboard/users");
|
||||||
|
await page.waitForSelector('[data-testid="user-management-page"]', { timeout: 10000 });
|
||||||
|
|
||||||
|
const table = page.locator("table").first();
|
||||||
|
await table.waitFor({ timeout: 5000 });
|
||||||
|
|
||||||
|
// Light theme table
|
||||||
|
await page.evaluate(() => {
|
||||||
|
document.documentElement.classList.remove("dark");
|
||||||
|
document.documentElement.classList.add("light");
|
||||||
|
});
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
|
||||||
|
await expect(table).toHaveScreenshot("table-light-theme.png", {
|
||||||
|
animations: "disabled",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Dark theme table
|
||||||
|
await page.evaluate(() => {
|
||||||
|
document.documentElement.classList.remove("light");
|
||||||
|
document.documentElement.classList.add("dark");
|
||||||
|
});
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
|
||||||
|
await expect(table).toHaveScreenshot("table-dark-theme.png", {
|
||||||
|
animations: "disabled",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Badges should render correctly in both themes", async ({ page }) => {
|
||||||
|
await page.goto("/dashboard/users");
|
||||||
|
await page.waitForSelector('[data-testid="user-management-page"]', { timeout: 10000 });
|
||||||
|
|
||||||
|
// Wait for badges to load
|
||||||
|
const badges = page.locator('[data-testid="role-badge"]');
|
||||||
|
if (await badges.count() > 0) {
|
||||||
|
await badges.first().waitFor({ timeout: 5000 });
|
||||||
|
|
||||||
|
// Light theme badges
|
||||||
|
await page.evaluate(() => {
|
||||||
|
document.documentElement.classList.remove("dark");
|
||||||
|
document.documentElement.classList.add("light");
|
||||||
|
});
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
|
||||||
|
await expect(badges.first()).toHaveScreenshot("badge-light-theme.png", {
|
||||||
|
animations: "disabled",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Dark theme badges
|
||||||
|
await page.evaluate(() => {
|
||||||
|
document.documentElement.classList.remove("light");
|
||||||
|
document.documentElement.classList.add("dark");
|
||||||
|
});
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
|
||||||
|
await expect(badges.first()).toHaveScreenshot("badge-dark-theme.png", {
|
||||||
|
animations: "disabled",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Focus states should be visible in both themes", async ({ page }) => {
|
||||||
|
await page.goto("/dashboard/users");
|
||||||
|
await page.waitForSelector('[data-testid="user-management-page"]', { timeout: 10000 });
|
||||||
|
|
||||||
|
const emailInput = page.locator('input[type="email"]').first();
|
||||||
|
await emailInput.waitFor({ timeout: 5000 });
|
||||||
|
|
||||||
|
// Test focus in light theme
|
||||||
|
await page.evaluate(() => {
|
||||||
|
document.documentElement.classList.remove("dark");
|
||||||
|
document.documentElement.classList.add("light");
|
||||||
|
});
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
|
||||||
|
await emailInput.focus();
|
||||||
|
await expect(emailInput).toHaveScreenshot("input-focus-light.png", {
|
||||||
|
animations: "disabled",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test focus in dark theme
|
||||||
|
await page.evaluate(() => {
|
||||||
|
document.documentElement.classList.remove("light");
|
||||||
|
document.documentElement.classList.add("dark");
|
||||||
|
});
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
|
||||||
|
await emailInput.focus();
|
||||||
|
await expect(emailInput).toHaveScreenshot("input-focus-dark.png", {
|
||||||
|
animations: "disabled",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Error states should be visible in both themes", async ({ page }) => {
|
||||||
|
await page.goto("/dashboard/users");
|
||||||
|
await page.waitForSelector('[data-testid="user-management-page"]', { timeout: 10000 });
|
||||||
|
|
||||||
|
// Mock error response
|
||||||
|
await page.route("**/api/dashboard/users", async (route) => {
|
||||||
|
if (route.request().method() === "POST") {
|
||||||
|
const json = { error: "Email already exists" };
|
||||||
|
await route.fulfill({ status: 400, json });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const emailInput = page.locator('input[type="email"]').first();
|
||||||
|
const submitButton = page.locator('button[type="submit"]').first();
|
||||||
|
|
||||||
|
await emailInput.waitFor({ timeout: 5000 });
|
||||||
|
await submitButton.waitFor({ timeout: 5000 });
|
||||||
|
|
||||||
|
// Fill form and submit to trigger error
|
||||||
|
await emailInput.fill("existing@example.com");
|
||||||
|
await submitButton.click();
|
||||||
|
|
||||||
|
// Wait for error message
|
||||||
|
await page.waitForSelector('[role="alert"]', { timeout: 5000 });
|
||||||
|
|
||||||
|
// Test error in light theme
|
||||||
|
await page.evaluate(() => {
|
||||||
|
document.documentElement.classList.remove("dark");
|
||||||
|
document.documentElement.classList.add("light");
|
||||||
|
});
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
|
||||||
|
const errorAlert = page.locator('[role="alert"]').first();
|
||||||
|
await expect(errorAlert).toHaveScreenshot("error-light-theme.png", {
|
||||||
|
animations: "disabled",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test error in dark theme
|
||||||
|
await page.evaluate(() => {
|
||||||
|
document.documentElement.classList.remove("light");
|
||||||
|
document.documentElement.classList.add("dark");
|
||||||
|
});
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
|
||||||
|
await expect(errorAlert).toHaveScreenshot("error-dark-theme.png", {
|
||||||
|
animations: "disabled",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Loading states should be visible in both themes", async ({ page }) => {
|
||||||
|
// Mock slow loading
|
||||||
|
await page.route("**/api/dashboard/users", async (route) => {
|
||||||
|
if (route.request().method() === "GET") {
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 2000));
|
||||||
|
const json = { users: [] };
|
||||||
|
await route.fulfill({ json });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.goto("/dashboard/users");
|
||||||
|
|
||||||
|
// Capture loading state in light theme
|
||||||
|
await page.evaluate(() => {
|
||||||
|
document.documentElement.classList.remove("dark");
|
||||||
|
document.documentElement.classList.add("light");
|
||||||
|
});
|
||||||
|
|
||||||
|
const loadingElement = page.locator('text="Loading users..."').first();
|
||||||
|
if (await loadingElement.count() > 0) {
|
||||||
|
await expect(loadingElement).toHaveScreenshot("loading-light-theme.png", {
|
||||||
|
animations: "disabled",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Capture loading state in dark theme
|
||||||
|
await page.evaluate(() => {
|
||||||
|
document.documentElement.classList.remove("light");
|
||||||
|
document.documentElement.classList.add("dark");
|
||||||
|
});
|
||||||
|
|
||||||
|
if (await loadingElement.count() > 0) {
|
||||||
|
await expect(loadingElement).toHaveScreenshot("loading-dark-theme.png", {
|
||||||
|
animations: "disabled",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Empty states should render correctly in both themes", async ({ page }) => {
|
||||||
|
// Mock empty response
|
||||||
|
await page.route("**/api/dashboard/users", async (route) => {
|
||||||
|
if (route.request().method() === "GET") {
|
||||||
|
const json = { users: [] };
|
||||||
|
await route.fulfill({ json });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.goto("/dashboard/users");
|
||||||
|
await page.waitForSelector('[data-testid="user-management-page"]', { timeout: 10000 });
|
||||||
|
|
||||||
|
// Wait for empty state
|
||||||
|
await page.waitForSelector('text="No users found"', { timeout: 5000 });
|
||||||
|
|
||||||
|
// Light theme empty state
|
||||||
|
await page.evaluate(() => {
|
||||||
|
document.documentElement.classList.remove("dark");
|
||||||
|
document.documentElement.classList.add("light");
|
||||||
|
});
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
|
||||||
|
const emptyState = page.locator('text="No users found"').first();
|
||||||
|
await expect(emptyState.locator("..")).toHaveScreenshot("empty-state-light.png", {
|
||||||
|
animations: "disabled",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Dark theme empty state
|
||||||
|
await page.evaluate(() => {
|
||||||
|
document.documentElement.classList.remove("light");
|
||||||
|
document.documentElement.classList.add("dark");
|
||||||
|
});
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
|
||||||
|
await expect(emptyState.locator("..")).toHaveScreenshot("empty-state-dark.png", {
|
||||||
|
animations: "disabled",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Theme transition should be smooth", async ({ page }) => {
|
||||||
|
await page.goto("/dashboard/users");
|
||||||
|
await page.waitForSelector('[data-testid="user-management-page"]', { timeout: 10000 });
|
||||||
|
|
||||||
|
// Start with light theme
|
||||||
|
await page.evaluate(() => {
|
||||||
|
document.documentElement.classList.remove("dark");
|
||||||
|
document.documentElement.classList.add("light");
|
||||||
|
});
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
|
||||||
|
// Find theme toggle if it exists
|
||||||
|
const themeToggle = page.locator('[data-testid="theme-toggle"]').first();
|
||||||
|
|
||||||
|
if (await themeToggle.count() > 0) {
|
||||||
|
// Record video during theme switch
|
||||||
|
await page.video()?.path();
|
||||||
|
|
||||||
|
// Toggle theme
|
||||||
|
await themeToggle.click();
|
||||||
|
|
||||||
|
// Wait for transition to complete
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
|
||||||
|
// Verify dark theme is applied
|
||||||
|
const isDarkMode = await page.evaluate(() => {
|
||||||
|
return document.documentElement.classList.contains("dark");
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(isDarkMode).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user