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
147 changes: 147 additions & 0 deletions src/course-lifecycle/components/BlockCommentsPanel.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
import {
fireEvent, initializeMocks, render, screen,
} from '@src/testUtils';
import { BlockCommentsPanel } from './BlockCommentsPanel';
import { mockBlockReviewComment } from '../data/api.mock';

const mockUseBlockComments = jest.fn();
const mockCreateMutate = jest.fn();
const mockResolveMutate = jest.fn();
const mockDeleteMutate = jest.fn();

jest.mock('@src/course-lifecycle/data/apiHooks', () => ({
useBlockComments: (...args: any[]) => mockUseBlockComments(...args),
useCreateComment: () => ({ mutate: mockCreateMutate, isPending: false }),
useResolveComment: () => ({ mutate: mockResolveMutate, isPending: false }),
useDeleteComment: () => ({ mutate: mockDeleteMutate, isPending: false }),
}));

const usageKey = 'block-v1:TestOrg+TestCourse+2025_T1+type@vertical+block@unit1';

/** Expand the Collapsible panel — its body is not rendered until opened. */
const expandPanel = () => {
fireEvent.click(screen.getByRole('button', { name: 'Comments' }));
};

describe('<BlockCommentsPanel />', () => {
beforeEach(() => {
initializeMocks();
mockUseBlockComments.mockReturnValue({ data: [], isLoading: false });
});

it('shows loading spinner while fetching comments', () => {
mockUseBlockComments.mockReturnValue({ data: undefined, isLoading: true });
const { container } = render(<BlockCommentsPanel usageKey={usageKey} />);
expandPanel();
expect(container.querySelector('.spinner-border')).toBeInTheDocument();
});

it('shows "No comments yet." when comments list is empty', () => {
render(<BlockCommentsPanel usageKey={usageKey} />);
expandPanel();
expect(screen.getByText('No comments yet.')).toBeInTheDocument();
});

it('renders comment author and text', () => {
const comment = mockBlockReviewComment({ author: 'reviewer', comment: 'Please fix this issue' });
mockUseBlockComments.mockReturnValue({ data: [comment], isLoading: false });
render(<BlockCommentsPanel usageKey={usageKey} />);
expandPanel();
expect(screen.getByText('reviewer')).toBeInTheDocument();
expect(screen.getByText('Please fix this issue')).toBeInTheDocument();
});

it('shows "Resolve" button for unresolved comment', () => {
const comment = mockBlockReviewComment({ resolved: false });
mockUseBlockComments.mockReturnValue({ data: [comment], isLoading: false });
render(<BlockCommentsPanel usageKey={usageKey} />);
expandPanel();
expect(screen.getByRole('button', { name: 'Resolve' })).toBeInTheDocument();
});

it('shows "Resolved" badge and no Resolve button for resolved comment', () => {
const comment = mockBlockReviewComment({ resolved: true });
mockUseBlockComments.mockReturnValue({ data: [comment], isLoading: false });
render(<BlockCommentsPanel usageKey={usageKey} />);
expandPanel();
expect(screen.getByText('Resolved')).toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Resolve' })).not.toBeInTheDocument();
});

it('shows "Delete" button for comment authored by the current user (abc123)', () => {
// Default initializeMocks user is { username: 'abc123' }
const comment = mockBlockReviewComment({ author: 'abc123' });
mockUseBlockComments.mockReturnValue({ data: [comment], isLoading: false });
render(<BlockCommentsPanel usageKey={usageKey} />);
expandPanel();
expect(screen.getByRole('button', { name: 'Delete' })).toBeInTheDocument();
});

it('does not show "Delete" button for comment authored by another user', () => {
const comment = mockBlockReviewComment({ author: 'some_other_user' });
mockUseBlockComments.mockReturnValue({ data: [comment], isLoading: false });
render(<BlockCommentsPanel usageKey={usageKey} />);
expandPanel();
expect(screen.queryByRole('button', { name: 'Delete' })).not.toBeInTheDocument();
});

it('calls resolveMutation.mutate with comment id when Resolve is clicked', () => {
const comment = mockBlockReviewComment({ id: 42, resolved: false });
mockUseBlockComments.mockReturnValue({ data: [comment], isLoading: false });
render(<BlockCommentsPanel usageKey={usageKey} />);
expandPanel();
fireEvent.click(screen.getByRole('button', { name: 'Resolve' }));
expect(mockResolveMutate).toHaveBeenCalledWith(42);
});

it('calls deleteMutation.mutate with comment id when Delete is clicked', () => {
const comment = mockBlockReviewComment({ id: 42, author: 'abc123' });
mockUseBlockComments.mockReturnValue({ data: [comment], isLoading: false });
render(<BlockCommentsPanel usageKey={usageKey} />);
expandPanel();
fireEvent.click(screen.getByRole('button', { name: 'Delete' }));
expect(mockDeleteMutate).toHaveBeenCalledWith(42);
});

it('calls createMutation.mutate with trimmed text when Add Comment is clicked', () => {
render(<BlockCommentsPanel usageKey={usageKey} />);
expandPanel();
const textarea = screen.getByPlaceholderText('Add a comment...');
fireEvent.change(textarea, { target: { value: ' Review this block ' } });
fireEvent.click(screen.getByRole('button', { name: 'Add Comment' }));
expect(mockCreateMutate).toHaveBeenCalledWith('Review this block', expect.any(Object));
});

it('"Add Comment" button is disabled when textarea is empty', () => {
render(<BlockCommentsPanel usageKey={usageKey} />);
expandPanel();
expect(screen.getByRole('button', { name: 'Add Comment' })).toBeDisabled();
});

it('"Add Comment" button becomes enabled when textarea has content', () => {
render(<BlockCommentsPanel usageKey={usageKey} />);
expandPanel();
const textarea = screen.getByPlaceholderText('Add a comment...');
fireEvent.change(textarea, { target: { value: 'A new comment' } });
expect(screen.getByRole('button', { name: 'Add Comment' })).not.toBeDisabled();
});

it('hides the Add Comment form in readOnly mode', () => {
render(<BlockCommentsPanel usageKey={usageKey} readOnly />);
expandPanel();
expect(screen.queryByPlaceholderText('Add a comment...')).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Add Comment' })).not.toBeInTheDocument();
});

it('renders multiple comments in the panel body', () => {
const comments = [
mockBlockReviewComment({ id: 1, comment: 'First comment' }),
mockBlockReviewComment({ id: 2, comment: 'Second comment' }),
];
mockUseBlockComments.mockReturnValue({ data: comments, isLoading: false });
render(<BlockCommentsPanel usageKey={usageKey} />);
expandPanel();
expect(screen.getByText('First comment')).toBeInTheDocument();
expect(screen.getByText('Second comment')).toBeInTheDocument();
});
});
112 changes: 112 additions & 0 deletions src/course-lifecycle/components/BlockCommentsPanel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import { useState } from 'react';
import {
Badge,
Button,
Collapsible,
Form,
Spinner,
} from '@openedx/paragon';
import { getAuthenticatedUser } from '@edx/frontend-platform/auth';

import {
useBlockComments, useCreateComment, useDeleteComment, useResolveComment,
} from '../data/apiHooks';

interface Props {
usageKey: string;
readOnly?: boolean;
}

export const BlockCommentsPanel = ({ usageKey, readOnly = false }: Props) => {
const [newComment, setNewComment] = useState('');
const currentUsername = getAuthenticatedUser()?.username;
const { data: comments, isLoading } = useBlockComments(usageKey);
const createMutation = useCreateComment(usageKey);
const resolveMutation = useResolveComment(usageKey);
const deleteMutation = useDeleteComment(usageKey);

const handleAdd = () => {
if (!newComment.trim()) {
return;
}
createMutation.mutate(newComment.trim(), {
onSuccess: () => setNewComment(''),
});
};

return (
<Collapsible title="Comments" className="lifecycle-comments mt-3">
<Collapsible.Trigger className="d-flex align-items-center justify-content-between w-100 border-0 bg-transparent p-0">
<span className="small font-weight-bold">Comments</span>
{comments && comments.length > 0 && (
<Badge variant="light">{comments.length}</Badge>
)}
</Collapsible.Trigger>
<Collapsible.Body>
{isLoading && <Spinner animation="border" size="sm" />}
{comments && comments.length === 0 && (
<p className="small text-muted mb-2">No comments yet.</p>
)}
{comments?.map((c) => (
<div
key={c.id}
className={`lifecycle-comment mb-2 p-2 border rounded ${c.resolved ? 'text-muted' : ''}`}
>
<div className="d-flex justify-content-between align-items-start">
<span className="small font-weight-bold">{c.author}</span>
<div className="d-flex gap-1 align-items-center">
{c.resolved && <Badge variant="light" className="small">Resolved</Badge>}
{!c.resolved && (
<Button
variant="link"
size="sm"
className="p-0"
disabled={resolveMutation.isPending}
onClick={() => resolveMutation.mutate(c.id)}
>
Resolve
</Button>
)}
{c.author === currentUsername && (
<Button
variant="link"
size="sm"
className="p-0 text-danger"
disabled={deleteMutation.isPending}
onClick={() => deleteMutation.mutate(c.id)}
>
Delete
</Button>
)}
</div>
</div>
<p className="small mb-1">{c.comment}</p>
<span className="x-small text-muted">{new Date(c.created).toLocaleDateString()}</span>
</div>
))}
{!readOnly && (
<>
<Form.Group className="mt-2">
<Form.Control
as="textarea"
rows={2}
placeholder="Add a comment..."
value={newComment}
onChange={(e) => setNewComment(e.target.value)}
/>
</Form.Group>
<Button
variant="outline-primary"
size="sm"
className="mt-1"
disabled={!newComment.trim() || createMutation.isPending}
onClick={handleAdd}
>
{createMutation.isPending ? <Spinner animation="border" size="sm" /> : 'Add Comment'}
</Button>
</>
)}
</Collapsible.Body>
</Collapsible>
);
};
134 changes: 134 additions & 0 deletions src/course-lifecycle/components/CourseCommentsPanel.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
import {
fireEvent, initializeMocks, render, screen, waitFor,
} from '@src/testUtils';
import { CourseCommentsPanel } from './CourseCommentsPanel';
import { mockBlockReviewComment } from '../data/api.mock';

const mockUseCourseComments = jest.fn();
const mockCreateCourseMutate = jest.fn();
const mockResolveComment = jest.fn();
const mockDeleteComment = jest.fn();

jest.mock('@src/course-lifecycle/data/apiHooks', () => ({
useCourseComments: (...args: any[]) => mockUseCourseComments(...args),
useCreateCourseComment: () => ({ mutate: mockCreateCourseMutate, isPending: false }),
lifecycleQueryKeys: {
courseComments: (id: string) => ['lifecycle', 'course', id, 'comments'],
},
}));

jest.mock('@src/course-lifecycle/data/api', () => ({
resolveComment: (...args: any[]) => mockResolveComment(...args),
deleteComment: (...args: any[]) => mockDeleteComment(...args),
}));

const courseId = 'course-v1:TestOrg+TestCourse+2025_T1';

/** Expand the Collapsible panel — its body is not rendered until opened. */
const expandPanel = () => {
fireEvent.click(screen.getByRole('button', { name: 'Comments' }));
};

describe('<CourseCommentsPanel />', () => {
beforeEach(() => {
initializeMocks();
mockUseCourseComments.mockReturnValue({ data: [], isLoading: false });
mockResolveComment.mockResolvedValue({ id: 1, resolved: true });
mockDeleteComment.mockResolvedValue(undefined);
});

it('shows loading spinner while fetching comments', () => {
mockUseCourseComments.mockReturnValue({ data: undefined, isLoading: true });
const { container } = render(<CourseCommentsPanel courseId={courseId} />);
expandPanel();
expect(container.querySelector('.spinner-border')).toBeInTheDocument();
});

it('shows "No comments yet." when comments list is empty', () => {
render(<CourseCommentsPanel courseId={courseId} />);
expandPanel();
expect(screen.getByText('No comments yet.')).toBeInTheDocument();
});

it('renders comment author and text', () => {
const comment = mockBlockReviewComment({ author: 'admin', comment: 'Course needs more examples' });
mockUseCourseComments.mockReturnValue({ data: [comment], isLoading: false });
render(<CourseCommentsPanel courseId={courseId} />);
expandPanel();
expect(screen.getByText('admin')).toBeInTheDocument();
expect(screen.getByText('Course needs more examples')).toBeInTheDocument();
});

it('shows "Resolve" button for unresolved comment', () => {
const comment = mockBlockReviewComment({ resolved: false });
mockUseCourseComments.mockReturnValue({ data: [comment], isLoading: false });
render(<CourseCommentsPanel courseId={courseId} />);
expandPanel();
expect(screen.getByRole('button', { name: 'Resolve' })).toBeInTheDocument();
});

it('shows "Resolved" badge and no Resolve button for resolved comment', () => {
const comment = mockBlockReviewComment({ resolved: true });
mockUseCourseComments.mockReturnValue({ data: [comment], isLoading: false });
render(<CourseCommentsPanel courseId={courseId} />);
expandPanel();
expect(screen.getByText('Resolved')).toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Resolve' })).not.toBeInTheDocument();
});

it('shows "Delete" button for comment authored by the current user', () => {
const comment = mockBlockReviewComment({ author: 'abc123' });
mockUseCourseComments.mockReturnValue({ data: [comment], isLoading: false });
render(<CourseCommentsPanel courseId={courseId} />);
expandPanel();
expect(screen.getByRole('button', { name: 'Delete' })).toBeInTheDocument();
});

it('does not show "Delete" button for comment authored by another user', () => {
const comment = mockBlockReviewComment({ author: 'another_user' });
mockUseCourseComments.mockReturnValue({ data: [comment], isLoading: false });
render(<CourseCommentsPanel courseId={courseId} />);
expandPanel();
expect(screen.queryByRole('button', { name: 'Delete' })).not.toBeInTheDocument();
});

it('calls resolveComment api when Resolve is clicked', async () => {
const comment = mockBlockReviewComment({ id: 7, resolved: false });
mockUseCourseComments.mockReturnValue({ data: [comment], isLoading: false });
render(<CourseCommentsPanel courseId={courseId} />);
expandPanel();
fireEvent.click(screen.getByRole('button', { name: 'Resolve' }));
await waitFor(() => expect(mockResolveComment).toHaveBeenCalledWith(7));
});

it('calls deleteComment api when Delete is clicked', async () => {
const comment = mockBlockReviewComment({ id: 7, author: 'abc123' });
mockUseCourseComments.mockReturnValue({ data: [comment], isLoading: false });
render(<CourseCommentsPanel courseId={courseId} />);
expandPanel();
fireEvent.click(screen.getByRole('button', { name: 'Delete' }));
await waitFor(() => expect(mockDeleteComment).toHaveBeenCalledWith(7));
});

it('calls createMutation.mutate with trimmed text when Add Comment is clicked', () => {
render(<CourseCommentsPanel courseId={courseId} />);
expandPanel();
const textarea = screen.getByPlaceholderText('Add a comment...');
fireEvent.change(textarea, { target: { value: ' Course feedback ' } });
fireEvent.click(screen.getByRole('button', { name: 'Add Comment' }));
expect(mockCreateCourseMutate).toHaveBeenCalledWith('Course feedback', expect.any(Object));
});

it('"Add Comment" button is disabled when textarea is empty', () => {
render(<CourseCommentsPanel courseId={courseId} />);
expandPanel();
expect(screen.getByRole('button', { name: 'Add Comment' })).toBeDisabled();
});

it('always shows the Add Comment form (no readOnly mode)', () => {
render(<CourseCommentsPanel courseId={courseId} />);
expandPanel();
expect(screen.getByPlaceholderText('Add a comment...')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Add Comment' })).toBeInTheDocument();
});
});
Loading
Loading