diff --git a/src/components/dashboard/DashboardFilters.tsx b/src/components/dashboard/DashboardFilters.tsx index 03ddc21a..f800b51b 100644 --- a/src/components/dashboard/DashboardFilters.tsx +++ b/src/components/dashboard/DashboardFilters.tsx @@ -7,7 +7,8 @@ 'use client'; import React, { useState, useCallback, useMemo } from 'react'; -import { Filter, X, RotateCcw, LifeBuoy } from 'lucide-react'; +import { Filter, RotateCcw, LifeBuoy } from 'lucide-react'; +import { Badge } from '@/components'; import { TimeRange, AggregationType, CHART_COLOR_PALETTE } from '@/utils/visualizationUtils'; import type { DashboardFiltersState } from '@/hooks/useDashboardData'; import { useInternationalization } from '@/hooks/useInternationalization'; @@ -146,23 +147,18 @@ export const DashboardFilters = React.memo( )} > {filters.timeRange !== '30d' && ( - onFiltersChange({ timeRange: '30d' })} + removeLabel={translateWithFallback( + t, + 'dashboard.analytics.filters.removeTimeRange', + 'Remove time range filter', + )} > {timeRangeOptions.find((option) => option.value === filters.timeRange)?.label} - - + )} {filters.categories.map((cat, i) => ( ( )} className="hover:opacity-75 transition-opacity" > - + + × + ))} diff --git a/src/components/index.ts b/src/components/index.ts index 490b771d..c2f79075 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -5,6 +5,8 @@ */ export * from './ui/Accordion'; +export { Badge, badgeVariants } from './ui/Badge'; +export type { BadgeProps } from './ui/Badge'; export { Button, buttonVariants } from './ui/Button'; export type { ButtonProps } from './ui/Button'; export { ButtonGroup } from './ui/ButtonGroup'; diff --git a/src/components/ui/Badge.tsx b/src/components/ui/Badge.tsx new file mode 100644 index 00000000..89d8f0af --- /dev/null +++ b/src/components/ui/Badge.tsx @@ -0,0 +1,70 @@ +'use client'; + +import React from 'react'; +import { cva, type VariantProps } from 'class-variance-authority'; +import { cn } from '@/lib/utils'; +import { X } from 'lucide-react'; + +const badgeVariants = cva( + 'inline-flex items-center gap-1 rounded-full px-2.5 py-0.5 text-xs font-medium transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-500', + { + variants: { + variant: { + default: 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300', + success: 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300', + warning: 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/40 dark:text-yellow-300', + danger: 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300', + info: 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300', + outline: 'border border-gray-300 text-gray-700 dark:border-gray-600 dark:text-gray-300', + }, + size: { + sm: 'px-1.5 py-0.5 text-xs', + md: 'px-2.5 py-0.5 text-xs', + lg: 'px-3 py-1 text-sm', + }, + }, + defaultVariants: { + variant: 'default', + size: 'md', + }, + }, +); + +export interface BadgeProps + extends React.HTMLAttributes, + VariantProps { + onRemove?: () => void; + removeLabel?: string; +} + +function Badge({ + className, + variant, + size, + children, + onRemove, + removeLabel = 'Remove', + ...props +}: BadgeProps) { + return ( + + {children} + {onRemove && ( + + )} + + ); +} + +Badge.displayName = 'Badge'; + +export { Badge, badgeVariants }; diff --git a/src/components/ui/__tests__/Badge.test.tsx b/src/components/ui/__tests__/Badge.test.tsx new file mode 100644 index 00000000..2d6132a6 --- /dev/null +++ b/src/components/ui/__tests__/Badge.test.tsx @@ -0,0 +1,98 @@ +import React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { Badge } from '../Badge'; + +describe('Badge', () => { + it('renders with default props', () => { + render(Default); + const badge = screen.getByText('Default'); + expect(badge).toBeInTheDocument(); + expect(badge.tagName).toBe('SPAN'); + }); + + it('renders variant classes correctly', () => { + const { rerender } = render(Default); + expect(screen.getByText('Default')).toHaveClass('bg-gray-100'); + + rerender(Success); + expect(screen.getByText('Success')).toHaveClass('bg-green-100'); + + rerender(Warning); + expect(screen.getByText('Warning')).toHaveClass('bg-yellow-100'); + + rerender(Danger); + expect(screen.getByText('Danger')).toHaveClass('bg-red-100'); + + rerender(Info); + expect(screen.getByText('Info')).toHaveClass('bg-blue-100'); + + rerender(Outline); + expect(screen.getByText('Outline')).toHaveClass('border'); + }); + + it('renders size classes correctly', () => { + const { rerender } = render(Small); + expect(screen.getByText('Small')).toHaveClass('px-1.5'); + + rerender(Medium); + expect(screen.getByText('Medium')).toHaveClass('px-2.5'); + + rerender(Large); + expect(screen.getByText('Large')).toHaveClass('px-3'); + }); + + it('renders a remove button when onRemove is provided', () => { + const onRemove = vi.fn(); + render(Dismissible); + const removeBtn = screen.getByRole('button'); + expect(removeBtn).toBeInTheDocument(); + fireEvent.click(removeBtn); + expect(onRemove).toHaveBeenCalledTimes(1); + }); + + it('calls onRemove with stopPropagation on remove click', () => { + const onRemove = vi.fn(); + render( +
+ Nested +
, + ); + const removeBtn = screen.getByRole('button'); + fireEvent.click(removeBtn); + expect(onRemove).toHaveBeenCalledTimes(1); + }); + + it('does not render remove button when onRemove is not provided', () => { + render(No Remove); + expect(screen.queryByRole('button')).not.toBeInTheDocument(); + }); + + it('uses custom remove aria-label', () => { + const onRemove = vi.fn(); + render( + + Custom + , + ); + expect(screen.getByRole('button')).toHaveAttribute('aria-label', 'Custom remove label'); + }); + + it('applies custom className', () => { + render(Custom Class); + expect(screen.getByText('Custom Class')).toHaveClass('custom-badge'); + }); + + it('supports additional HTML span attributes', () => { + render( + + Aria + , + ); + const badge = screen.getByRole('status'); + expect(badge).toHaveAttribute('aria-label', 'Status badge'); + }); + + it('has displayName', () => { + expect(Badge.displayName).toBe('Badge'); + }); +}); diff --git a/src/components/ui/index.ts b/src/components/ui/index.ts index 482c47c9..58772e71 100644 --- a/src/components/ui/index.ts +++ b/src/components/ui/index.ts @@ -9,6 +9,8 @@ export { type BreadcrumbItem, type BreadcrumbsProps, } from './Breadcrumbs'; +export { Badge, badgeVariants } from './Badge'; +export type { BadgeProps } from './Badge'; export { Button } from './Button'; export { ButtonGroup } from './ButtonGroup'; export { EmptyState } from './EmptyState'; diff --git a/src/lib/settings/__tests__/store.test.ts b/src/lib/settings/__tests__/store.test.ts index efdacbb2..f32d6650 100644 --- a/src/lib/settings/__tests__/store.test.ts +++ b/src/lib/settings/__tests__/store.test.ts @@ -17,4 +17,4 @@ describe('useSettingsStore', () => { expect(useSettingsStore.getState().settings.language).toBe('fr'); }); -}); \ No newline at end of file +}); diff --git a/src/store/cmsStore.test.ts b/src/store/cmsStore.test.ts index 7db5f566..7f78c939 100644 --- a/src/store/cmsStore.test.ts +++ b/src/store/cmsStore.test.ts @@ -18,9 +18,9 @@ describe('cmsStore persist middleware', () => { it('persists history, historyIndex, and course state to sessionStorage', () => { const course = { id: '1', title: 'Test Course', description: 'Test', modules: [] }; - + useCMSStore.getState().setCourse(course); - + // Check sessionStorage const stored = JSON.parse(sessionStorage.getItem('cms-storage') || '{}'); expect(stored.state).toBeDefined(); @@ -32,7 +32,7 @@ describe('cmsStore persist middleware', () => { it('rehydrates correctly and allows undo after refresh', async () => { const course1 = { id: '1', title: 'Course v1', description: '', modules: [] }; const course2 = { id: '1', title: 'Course v2', description: '', modules: [] }; - + useCMSStore.getState().setCourse(course1); useCMSStore.getState().updateCourse({ title: 'Course v2' }); @@ -60,7 +60,7 @@ describe('cmsStore persist middleware', () => { // Perform undo useCMSStore.getState().undo(); - + // Verify undo worked const stateAfterUndo = useCMSStore.getState(); expect(stateAfterUndo.course.title).toBe('Course v1'); @@ -75,7 +75,7 @@ describe('cmsStore persist middleware', () => { const state = useCMSStore.getState(); expect(state.history.length).toBe(20); expect(state.historyIndex).toBe(19); - + // The first 5 should be dropped, so the oldest item is v5 expect(state.history[0].title).toBe('Course v5'); expect(state.history[19].title).toBe('Course v24'); diff --git a/src/store/cmsStore.ts b/src/store/cmsStore.ts index 87ab148d..61662c9e 100644 --- a/src/store/cmsStore.ts +++ b/src/store/cmsStore.ts @@ -49,11 +49,11 @@ export const useCMSStore = create()( set((state) => { let newHistory = state.history.slice(0, state.historyIndex + 1); newHistory.push(course); - + if (newHistory.length > 20) { newHistory = newHistory.slice(newHistory.length - 20); } - + return { course, history: newHistory, @@ -68,7 +68,7 @@ export const useCMSStore = create()( let newHistory = state.history.slice(0, state.historyIndex + 1); newHistory.push(updatedCourse); - + if (newHistory.length > 20) { newHistory = newHistory.slice(newHistory.length - 20); } @@ -115,7 +115,9 @@ export const useCMSStore = create()( updateUploadProgress: (id, progress) => { set((state) => ({ - mediaQueue: state.mediaQueue.map((task) => (task.id === id ? { ...task, progress } : task)), + mediaQueue: state.mediaQueue.map((task) => + task.id === id ? { ...task, progress } : task, + ), })); }, @@ -139,6 +141,6 @@ export const useCMSStore = create()( history: state.history, historyIndex: state.historyIndex, }), - } - ) + }, + ), );