Skip to content
Closed
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
92 changes: 76 additions & 16 deletions src/components/Reviews/ReviewsList.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
"use client";

import { useEffect, useMemo, useState } from "react";
import RatingSummary from "./RatingSummary";
import { AlertCircle, MessageSquarePlus, RefreshCw } from "lucide-react";
import { RatingSummary } from "./RatingSummary";

interface Review {
id: string;
Expand All @@ -23,9 +24,17 @@ type VoteState = "up" | "down" | null;

interface ReviewsListProps {
reviews: Review[];
isLoading?: boolean;
error?: string | null;
onRetry?: () => void;
}

export default function ReviewsList({ reviews }: ReviewsListProps) {
export default function ReviewsList({
reviews,
isLoading = false,
error = null,
onRetry,
}: ReviewsListProps) {
const [sortBy, setSortBy] = useState("mostRecent");
const [reviewList, setReviewList] = useState(reviews);
const [visibleCount, setVisibleCount] = useState(5);
Expand Down Expand Up @@ -88,6 +97,48 @@ export default function ReviewsList({ reviews }: ReviewsListProps) {
);
};

if (isLoading) {
return (
<div
role="status"
aria-label="Loading course reviews"
data-testid="reviews-loading"
className="space-y-4"
>
<div className="h-28 w-full rounded-xl bg-ink-50 animate-pulse" />
{Array.from({ length: 3 }).map((_, idx) => (
<div key={idx} className="h-32 w-full rounded-xl bg-ink-50 animate-pulse" />
))}
</div>
);
}

if (error) {
return (
<div
role="alert"
data-testid="reviews-error"
className="rounded-xl border border-red-200 bg-red-50 p-6 text-center space-y-3"
>
<AlertCircle className="h-8 w-8 text-red-500 mx-auto" />
<div>
<p className="text-sm font-semibold text-red-900">Unable to load course reviews</p>
<p className="text-xs text-red-700 mt-1">{error}</p>
</div>
{onRetry && (
<button
type="button"
onClick={onRetry}
className="inline-flex items-center gap-1.5 rounded-lg bg-red-100 px-3.5 py-2 text-xs font-semibold text-red-800 hover:bg-red-200 transition-colors"
>
<RefreshCw className="h-3.5 w-3.5" />
Retry
</button>
)}
</div>
);
}

return (
<div className="space-y-6">
<RatingSummary
Expand All @@ -96,22 +147,31 @@ export default function ReviewsList({ reviews }: ReviewsListProps) {
distribution={distribution}
/>

<div className="flex justify-end">
<select
value={sortBy}
onChange={(e) => setSortBy(e.target.value)}
className="rounded-lg border border-ink-200 px-3 py-2 text-sm"
>
<option value="mostRecent">Most Recent</option>
<option value="mostHelpful">Most Helpful</option>
<option value="highestRated">Highest Rated</option>
<option value="lowestRated">Lowest Rated</option>
</select>
</div>
{sortedReviews.length > 0 && (
<div className="flex justify-end">
<select
value={sortBy}
onChange={(e) => setSortBy(e.target.value)}
className="rounded-lg border border-ink-200 px-3 py-2 text-sm"
>
<option value="mostRecent">Most Recent</option>
<option value="mostHelpful">Most Helpful</option>
<option value="highestRated">Highest Rated</option>
<option value="lowestRated">Lowest Rated</option>
</select>
</div>
)}

{sortedReviews.length === 0 ? (
<div className="rounded-xl border border-dashed border-ink-200 p-8 text-center">
<p className="text-ink-500">No reviews yet.</p>
<div
data-testid="reviews-empty"
className="rounded-xl border border-dashed border-ink-200 p-10 text-center space-y-2"
>
<MessageSquarePlus className="h-8 w-8 text-ink-300 mx-auto" />
<p className="text-sm font-semibold text-ink-800">No reviews yet</p>
<p className="text-xs text-ink-500">
Be the first learner to share feedback and rate this course after enrolling.
</p>
</div>
) : (
<div className="space-y-6">
Expand Down
33 changes: 33 additions & 0 deletions src/components/Reviews/__tests__/ReviewsList.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
// @vitest-environment jsdom
import React from 'react';
import { fireEvent, render, screen } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import ReviewsList from '../ReviewsList';

describe('ReviewsList empty, loading, and error states (#203)', () => {
it('renders loading skeleton when isLoading is true', () => {
render(<ReviewsList reviews={[]} isLoading />);
expect(screen.getByTestId('reviews-loading')).toBeInTheDocument();
});

it('renders error state with retry callback when error is provided', () => {
const onRetry = vi.fn();
render(
<ReviewsList
reviews={[]}
error="Failed to load course reviews"
onRetry={onRetry}
/>
);
expect(screen.getByTestId('reviews-error')).toBeInTheDocument();
expect(screen.getByText('Failed to load course reviews')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: /Retry/i }));
expect(onRetry).toHaveBeenCalledTimes(1);
});

it('renders empty state when reviews list is empty', () => {
render(<ReviewsList reviews={[]} />);
expect(screen.getByTestId('reviews-empty')).toBeInTheDocument();
expect(screen.getByText('No reviews yet')).toBeInTheDocument();
});
});