Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 14 additions & 16 deletions src/components/dashboard/DashboardFilters.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -146,23 +147,18 @@ export const DashboardFilters = React.memo<DashboardFiltersProps>(
)}
>
{filters.timeRange !== '30d' && (
<span
<Badge
role="listitem"
className="inline-flex items-center gap-1 px-2 py-1 text-xs rounded-full bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300"
variant="default"
onRemove={() => onFiltersChange({ timeRange: '30d' })}
removeLabel={translateWithFallback(
t,
'dashboard.analytics.filters.removeTimeRange',
'Remove time range filter',
)}
>
{timeRangeOptions.find((option) => option.value === filters.timeRange)?.label}
<button
onClick={() => onFiltersChange({ timeRange: '30d' })}
aria-label={translateWithFallback(
t,
'dashboard.analytics.filters.removeTimeRange',
'Remove time range filter',
)}
className="hover:text-red-500 transition-colors"
>
<X className="w-3 h-3" />
</button>
</span>
</Badge>
)}
{filters.categories.map((cat, i) => (
<span
Expand All @@ -182,7 +178,9 @@ export const DashboardFilters = React.memo<DashboardFiltersProps>(
)}
className="hover:opacity-75 transition-opacity"
>
<X className="w-3 h-3" />
<span className="hover:opacity-75 transition-opacity w-3 h-3 inline-flex items-center justify-center">
&times;
</span>
</button>
</span>
))}
Expand Down
2 changes: 2 additions & 0 deletions src/components/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
70 changes: 70 additions & 0 deletions src/components/ui/Badge.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLSpanElement>,
VariantProps<typeof badgeVariants> {
onRemove?: () => void;
removeLabel?: string;
}

function Badge({
className,
variant,
size,
children,
onRemove,
removeLabel = 'Remove',
...props
}: BadgeProps) {
return (
<span className={cn(badgeVariants({ variant, size }), className)} {...props}>
{children}
{onRemove && (
<button
onClick={(e) => {
e.stopPropagation();
onRemove();
}}
aria-label={removeLabel}
className="hover:text-red-500 transition-colors flex-shrink-0"
>
<X className="w-3 h-3" />
</button>
)}
</span>
);
}

Badge.displayName = 'Badge';

export { Badge, badgeVariants };
98 changes: 98 additions & 0 deletions src/components/ui/__tests__/Badge.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<Badge>Default</Badge>);
const badge = screen.getByText('Default');
expect(badge).toBeInTheDocument();
expect(badge.tagName).toBe('SPAN');
});

it('renders variant classes correctly', () => {
const { rerender } = render(<Badge variant="default">Default</Badge>);
expect(screen.getByText('Default')).toHaveClass('bg-gray-100');

rerender(<Badge variant="success">Success</Badge>);
expect(screen.getByText('Success')).toHaveClass('bg-green-100');

rerender(<Badge variant="warning">Warning</Badge>);
expect(screen.getByText('Warning')).toHaveClass('bg-yellow-100');

rerender(<Badge variant="danger">Danger</Badge>);
expect(screen.getByText('Danger')).toHaveClass('bg-red-100');

rerender(<Badge variant="info">Info</Badge>);
expect(screen.getByText('Info')).toHaveClass('bg-blue-100');

rerender(<Badge variant="outline">Outline</Badge>);
expect(screen.getByText('Outline')).toHaveClass('border');
});

it('renders size classes correctly', () => {
const { rerender } = render(<Badge size="sm">Small</Badge>);
expect(screen.getByText('Small')).toHaveClass('px-1.5');

rerender(<Badge size="md">Medium</Badge>);
expect(screen.getByText('Medium')).toHaveClass('px-2.5');

rerender(<Badge size="lg">Large</Badge>);
expect(screen.getByText('Large')).toHaveClass('px-3');
});

it('renders a remove button when onRemove is provided', () => {
const onRemove = vi.fn();
render(<Badge onRemove={onRemove}>Dismissible</Badge>);
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(
<div onClick={vi.fn()}>
<Badge onRemove={onRemove}>Nested</Badge>
</div>,
);
const removeBtn = screen.getByRole('button');
fireEvent.click(removeBtn);
expect(onRemove).toHaveBeenCalledTimes(1);
});

it('does not render remove button when onRemove is not provided', () => {
render(<Badge>No Remove</Badge>);
expect(screen.queryByRole('button')).not.toBeInTheDocument();
});

it('uses custom remove aria-label', () => {
const onRemove = vi.fn();
render(
<Badge onRemove={onRemove} removeLabel="Custom remove label">
Custom
</Badge>,
);
expect(screen.getByRole('button')).toHaveAttribute('aria-label', 'Custom remove label');
});

it('applies custom className', () => {
render(<Badge className="custom-badge">Custom Class</Badge>);
expect(screen.getByText('Custom Class')).toHaveClass('custom-badge');
});

it('supports additional HTML span attributes', () => {
render(
<Badge role="status" aria-label="Status badge">
Aria
</Badge>,
);
const badge = screen.getByRole('status');
expect(badge).toHaveAttribute('aria-label', 'Status badge');
});

it('has displayName', () => {
expect(Badge.displayName).toBe('Badge');
});
});
2 changes: 2 additions & 0 deletions src/components/ui/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
2 changes: 1 addition & 1 deletion src/lib/settings/__tests__/store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,4 +17,4 @@ describe('useSettingsStore', () => {

expect(useSettingsStore.getState().settings.language).toBe('fr');
});
});
});
10 changes: 5 additions & 5 deletions src/store/cmsStore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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' });

Expand Down Expand Up @@ -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');
Expand All @@ -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');
Expand Down
14 changes: 8 additions & 6 deletions src/store/cmsStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,11 +49,11 @@ export const useCMSStore = create<CMSState>()(
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,
Expand All @@ -68,7 +68,7 @@ export const useCMSStore = create<CMSState>()(
let newHistory = state.history.slice(0, state.historyIndex + 1);

newHistory.push(updatedCourse);

if (newHistory.length > 20) {
newHistory = newHistory.slice(newHistory.length - 20);
}
Expand Down Expand Up @@ -115,7 +115,9 @@ export const useCMSStore = create<CMSState>()(

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,
),
}));
},

Expand All @@ -139,6 +141,6 @@ export const useCMSStore = create<CMSState>()(
history: state.history,
historyIndex: state.historyIndex,
}),
}
)
},
),
);