diff --git a/src/certificates/CertificatesPage.test.tsx b/src/certificates/CertificatesPage.test.tsx new file mode 100644 index 00000000..1e53f358 --- /dev/null +++ b/src/certificates/CertificatesPage.test.tsx @@ -0,0 +1,203 @@ +import { screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { MemoryRouter, Routes, Route } from 'react-router-dom'; +import CertificatesPage from './CertificatesPage'; +import { useCertificateConfig } from './data/apiHook'; +import { renderWithQueryClient } from '@src/testUtils'; +import messages from './messages'; + +jest.mock('./data/apiHook'); +jest.mock('./components/IssuedCertificates', () => ({ + __esModule: true, + default: () =>
Issued Certificates Component
, +})); +jest.mock('./components/CertificateGenerationHistory', () => ({ + __esModule: true, + default: () =>
Certificate Generation History Component
, +})); +jest.mock('@src/components/PageNotFound', () => ({ + __esModule: true, + default: () =>
Page Not Found
, +})); + +const mockUseCertificateConfig = useCertificateConfig as jest.MockedFunction; + +const renderWithProviders = (component: React.ReactElement, courseId = 'course-123') => { + return renderWithQueryClient( + + + + + + ); +}; + +describe('CertificatesPage', () => { + beforeEach(() => { + jest.clearAllMocks(); + + mockUseCertificateConfig.mockReturnValue({ + data: { enabled: true }, + isLoading: false, + error: null, + } as any); + }); + + it('should render page with title and tabs', () => { + renderWithProviders(); + + expect(screen.getByText(messages.certificatesTitle.defaultMessage)).toBeInTheDocument(); + expect(screen.getByRole('tab', { name: messages.issuedCertificatesTab.defaultMessage })).toBeInTheDocument(); + expect(screen.getByRole('tab', { name: messages.generationHistoryTab.defaultMessage })).toBeInTheDocument(); + }); + + it('should render IssuedCertificates component by default', () => { + renderWithProviders(); + + expect(screen.getByText('Issued Certificates Component')).toBeInTheDocument(); + }); + + it('should switch to generation history tab', async () => { + const user = userEvent.setup(); + renderWithProviders(); + + const historyTab = screen.getByRole('tab', { name: messages.generationHistoryTab.defaultMessage }); + await user.click(historyTab); + + expect(screen.getByText('Certificate Generation History Component')).toBeInTheDocument(); + }); + + it('should switch back to issued certificates tab', async () => { + const user = userEvent.setup(); + renderWithProviders(); + + const historyTab = screen.getByRole('tab', { name: messages.generationHistoryTab.defaultMessage }); + await user.click(historyTab); + + const issuedTab = screen.getByRole('tab', { name: messages.issuedCertificatesTab.defaultMessage }); + await user.click(issuedTab); + + expect(screen.getByText('Issued Certificates Component')).toBeInTheDocument(); + }); + + it('should render loading state', () => { + mockUseCertificateConfig.mockReturnValue({ + data: undefined, + isLoading: true, + error: null, + } as any); + + const { container } = renderWithProviders(); + + // Check for spinner element + expect(container.querySelector('.spinner-border')).toBeInTheDocument(); + }); + + it('should render PageNotFound when certificates are not enabled', () => { + mockUseCertificateConfig.mockReturnValue({ + data: { enabled: false }, + isLoading: false, + error: null, + } as any); + + renderWithProviders(); + + expect(screen.getByText('Page Not Found')).toBeInTheDocument(); + }); + + it('should render PageNotFound when 404 error occurs', () => { + mockUseCertificateConfig.mockReturnValue({ + data: undefined, + isLoading: false, + error: { response: { status: 404 } }, + } as any); + + renderWithProviders(); + + expect(screen.getByText('Page Not Found')).toBeInTheDocument(); + }); + + it('should not render PageNotFound for non-404 errors', () => { + mockUseCertificateConfig.mockReturnValue({ + data: undefined, + isLoading: false, + error: { response: { status: 500 } }, + } as any); + + renderWithProviders(); + + expect(screen.queryByText('Page Not Found')).not.toBeInTheDocument(); + }); + + it('should call useCertificateConfig with correct courseId', () => { + renderWithProviders(, 'course-456'); + + expect(mockUseCertificateConfig).toHaveBeenCalledWith('course-456'); + }); + + it('should maintain active tab state when switching tabs', async () => { + const user = userEvent.setup(); + renderWithProviders(); + + const issuedTab = screen.getByRole('tab', { name: messages.issuedCertificatesTab.defaultMessage }); + expect(issuedTab).toHaveAttribute('aria-selected', 'true'); + + const historyTab = screen.getByRole('tab', { name: messages.generationHistoryTab.defaultMessage }); + expect(historyTab).toHaveAttribute('aria-selected', 'false'); + + await user.click(historyTab); + + await waitFor(() => { + expect(historyTab).toHaveAttribute('aria-selected', 'true'); + expect(issuedTab).toHaveAttribute('aria-selected', 'false'); + }); + }); + + it('should render certificates page when config is enabled', () => { + mockUseCertificateConfig.mockReturnValue({ + data: { enabled: true }, + isLoading: false, + error: null, + } as any); + + renderWithProviders(); + + expect(screen.getByText(messages.certificatesTitle.defaultMessage)).toBeInTheDocument(); + expect(screen.queryByText('Page Not Found')).not.toBeInTheDocument(); + }); + + it('should not render page content while loading', () => { + mockUseCertificateConfig.mockReturnValue({ + data: undefined, + isLoading: true, + error: null, + } as any); + + renderWithProviders(); + + expect(screen.queryByText(messages.certificatesTitle.defaultMessage)).not.toBeInTheDocument(); + expect(screen.queryByText('Issued Certificates Component')).not.toBeInTheDocument(); + }); + + it('should handle error without response object', () => { + mockUseCertificateConfig.mockReturnValue({ + data: undefined, + isLoading: false, + error: new Error('Network error'), + } as any); + + renderWithProviders(); + + expect(screen.queryByText('Page Not Found')).not.toBeInTheDocument(); + }); + + it('should render both tabs simultaneously', () => { + renderWithProviders(); + + const issuedTab = screen.getByRole('tab', { name: messages.issuedCertificatesTab.defaultMessage }); + const historyTab = screen.getByRole('tab', { name: messages.generationHistoryTab.defaultMessage }); + + expect(issuedTab).toBeInTheDocument(); + expect(historyTab).toBeInTheDocument(); + }); +}); diff --git a/src/certificates/CertificatesPage.tsx b/src/certificates/CertificatesPage.tsx index fea3c361..d8eaa95a 100644 --- a/src/certificates/CertificatesPage.tsx +++ b/src/certificates/CertificatesPage.tsx @@ -1,7 +1,57 @@ +import { useState } from 'react'; +import { useParams } from 'react-router-dom'; +import { useIntl } from '@openedx/frontend-base'; +import { Tabs, Tab, Spinner } from '@openedx/paragon'; +import messages from './messages'; +import IssuedCertificates from './components/IssuedCertificates'; +import CertificateGenerationHistory from './components/CertificateGenerationHistory'; +import { useCertificateConfig } from './data/apiHook'; +import PageNotFound from '@src/components/PageNotFound'; + const CertificatesPage = () => { + const intl = useIntl(); + const { courseId = '' } = useParams<{ courseId: string }>(); + const [activeTab, setActiveTab] = useState('issued'); + const { data: config, isLoading, error } = useCertificateConfig(courseId); + + // Check if we got a 404 or if certificates are not enabled + const is404 = (error as any)?.response?.status === 404; + const certificatesNotEnabled = !isLoading && config && !config.enabled; + + if (isLoading) { + return ( +
+ +
+ ); + } + + if (is404 || certificatesNotEnabled) { + return ; + } + return ( -
-

Certificates

+
+

{intl.formatMessage(messages.certificatesTitle)}

+ + setActiveTab(key as string)} + className="mb-3" + > + + + + + + +
); }; diff --git a/src/certificates/components/CertificateGenerationHistory.test.tsx b/src/certificates/components/CertificateGenerationHistory.test.tsx new file mode 100644 index 00000000..ad7978b3 --- /dev/null +++ b/src/certificates/components/CertificateGenerationHistory.test.tsx @@ -0,0 +1,146 @@ +import { screen } from '@testing-library/react'; +import { MemoryRouter, Routes, Route } from 'react-router-dom'; +import CertificateGenerationHistory from './CertificateGenerationHistory'; +import { useCertificateGenerationHistory } from '../data/apiHook'; +import { renderWithQueryClient } from '@src/testUtils'; +import messages from '../messages'; + +jest.mock('../data/apiHook'); + +const mockUseCertificateGenerationHistory = useCertificateGenerationHistory as jest.MockedFunction; + +const mockHistoryData = { + results: [ + { + taskName: 'Generate Certificates', + date: '2025-01-15T10:30:00Z', + details: 'Generated 50 certificates', + }, + { + taskName: 'Regenerate Certificates', + date: '2025-01-14T14:20:00Z', + details: 'Regenerated 10 certificates', + }, + ], + count: 2, +}; + +const renderWithProviders = (component: React.ReactElement, courseId = 'course-123') => { + return renderWithQueryClient( + + + + + + ); +}; + +describe('CertificateGenerationHistory', () => { + beforeEach(() => { + jest.clearAllMocks(); + + mockUseCertificateGenerationHistory.mockReturnValue({ + data: mockHistoryData, + isLoading: false, + error: null, + } as any); + }); + + it('should render table with history data', () => { + renderWithProviders(); + + expect(screen.getByText('Generate Certificates')).toBeInTheDocument(); + expect(screen.getByText('2025-01-15T10:30:00Z')).toBeInTheDocument(); + expect(screen.getByText('Generated 50 certificates')).toBeInTheDocument(); + expect(screen.getByText('Regenerate Certificates')).toBeInTheDocument(); + expect(screen.getByText('2025-01-14T14:20:00Z')).toBeInTheDocument(); + expect(screen.getByText('Regenerated 10 certificates')).toBeInTheDocument(); + }); + + it('should render loading state', () => { + mockUseCertificateGenerationHistory.mockReturnValue({ + data: undefined, + isLoading: true, + error: null, + } as any); + + const { container } = renderWithProviders(); + + // Check for spinner element + expect(container.querySelector('.spinner-border')).toBeInTheDocument(); + }); + + it('should render error state', () => { + mockUseCertificateGenerationHistory.mockReturnValue({ + data: undefined, + isLoading: false, + error: new Error('Failed to load history'), + } as any); + + renderWithProviders(); + + expect(screen.getByText('Error loading generation history')).toBeInTheDocument(); + }); + + it('should render empty state when no history', () => { + mockUseCertificateGenerationHistory.mockReturnValue({ + data: { results: [], count: 0 }, + isLoading: false, + error: null, + } as any); + + renderWithProviders(); + + expect(screen.getByText('No generation history found')).toBeInTheDocument(); + }); + + it('should call hook with correct parameters', () => { + renderWithProviders(, 'course-456'); + + expect(mockUseCertificateGenerationHistory).toHaveBeenCalledWith('course-456', { + page: 0, + pageSize: 20, + }); + }); + + it('should render table headers', () => { + renderWithProviders(); + + expect(screen.getByText(messages.taskName.defaultMessage)).toBeInTheDocument(); + expect(screen.getByText(messages.date.defaultMessage)).toBeInTheDocument(); + expect(screen.getByText(messages.details.defaultMessage)).toBeInTheDocument(); + }); + + it('should use pageSize of 20', () => { + renderWithProviders(); + + expect(mockUseCertificateGenerationHistory).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ pageSize: 20 }) + ); + }); + + it('should handle data with null values gracefully', () => { + mockUseCertificateGenerationHistory.mockReturnValue({ + data: undefined, + isLoading: false, + error: null, + } as any); + + renderWithProviders(); + + expect(screen.getByText('No generation history found')).toBeInTheDocument(); + }); + + it('should calculate page count correctly', () => { + mockUseCertificateGenerationHistory.mockReturnValue({ + data: { results: mockHistoryData.results, count: 45 }, + isLoading: false, + error: null, + } as any); + + renderWithProviders(); + + expect(screen.getByText('Generate Certificates')).toBeInTheDocument(); + }); +}); diff --git a/src/certificates/components/CertificateGenerationHistory.tsx b/src/certificates/components/CertificateGenerationHistory.tsx new file mode 100644 index 00000000..6253dac8 --- /dev/null +++ b/src/certificates/components/CertificateGenerationHistory.tsx @@ -0,0 +1,62 @@ +import { useState } from 'react'; +import { useParams } from 'react-router-dom'; +import { useIntl } from '@openedx/frontend-base'; +import { DataTable, Spinner } from '@openedx/paragon'; +import { useCertificateGenerationHistory } from '../data/apiHook'; +import messages from '../messages'; + +const CertificateGenerationHistory = () => { + const intl = useIntl(); + const { courseId = '' } = useParams<{ courseId: string }>(); + const [pageIndex] = useState(0); + const pageSize = 20; + + const { data, isLoading, error } = useCertificateGenerationHistory( + courseId, + { page: pageIndex, pageSize } + ); + + const columns = [ + { + Header: intl.formatMessage(messages.taskName), + accessor: 'taskName', + }, + { + Header: intl.formatMessage(messages.date), + accessor: 'date', + }, + { + Header: intl.formatMessage(messages.details), + accessor: 'details', + }, + ]; + + if (isLoading) { + return ( +
+ +
+ ); + } + + if (error) { + return
Error loading generation history
; + } + + return ( +
+ + + + + +
+ ); +}; + +export default CertificateGenerationHistory; diff --git a/src/certificates/components/IssuedCertificates.test.tsx b/src/certificates/components/IssuedCertificates.test.tsx new file mode 100644 index 00000000..4d9c07ea --- /dev/null +++ b/src/certificates/components/IssuedCertificates.test.tsx @@ -0,0 +1,469 @@ +import { screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { MemoryRouter, Routes, Route } from 'react-router-dom'; +import IssuedCertificates from './IssuedCertificates'; +import { useIssuedCertificates, useRegenerateCertificatesMutation } from '../data/apiHook'; +import { AlertProvider } from '@src/providers/AlertProvider'; +import { renderWithQueryClient } from '@src/testUtils'; +import messages from '../messages'; + +jest.mock('../data/apiHook'); + +const mockUseIssuedCertificates = useIssuedCertificates as jest.MockedFunction; +const mockUseRegenerateCertificatesMutation = useRegenerateCertificatesMutation as jest.MockedFunction; + +const mockCertificatesData = { + results: [ + { + username: 'john_doe', + email: 'john@example.com', + enrollmentTrack: 'verified', + certificateStatus: 'downloadable', + specialCase: null, + exceptionGranted: null, + exceptionNotes: null, + invalidatedBy: null, + invalidationDate: null, + }, + { + username: 'jane_smith', + email: 'jane@example.com', + enrollmentTrack: 'audit', + certificateStatus: 'notpassing', + specialCase: 'honor_code', + exceptionGranted: 'admin', + exceptionNotes: 'Special permission', + invalidatedBy: 'instructor', + invalidationDate: '2025-01-15', + }, + ], + count: 2, +}; + +const renderWithProviders = (component: React.ReactElement, courseId = 'course-123') => { + return renderWithQueryClient( + + + + + + + + ); +}; + +describe('IssuedCertificates', () => { + const mockMutate = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + + mockUseIssuedCertificates.mockReturnValue({ + data: mockCertificatesData, + isLoading: false, + error: null, + } as any); + + mockUseRegenerateCertificatesMutation.mockReturnValue({ + mutate: mockMutate, + isPending: false, + } as any); + }); + + it('should render table with certificate data', () => { + renderWithProviders(); + + expect(screen.getByText('john_doe')).toBeInTheDocument(); + expect(screen.getByText('john@example.com')).toBeInTheDocument(); + expect(screen.getByText('jane_smith')).toBeInTheDocument(); + expect(screen.getByText('jane@example.com')).toBeInTheDocument(); + }); + + it('should render regenerate button', () => { + renderWithProviders(); + + const button = screen.getByRole('button', { name: messages.regenerateCertificates.defaultMessage }); + expect(button).toBeInTheDocument(); + }); + + it('should disable regenerate button when filter is "all"', () => { + renderWithProviders(); + + const button = screen.getByRole('button', { name: messages.regenerateCertificates.defaultMessage }); + expect(button).toBeDisabled(); + }); + + it('should disable regenerate button when filter is "invalidated"', async () => { + const user = userEvent.setup(); + renderWithProviders(); + + const dropdown = screen.getByText(messages.allLearners.defaultMessage); + await user.click(dropdown); + + const invalidatedOption = screen.getByText(messages.invalidated.defaultMessage); + await user.click(invalidatedOption); + + const button = screen.getByRole('button', { name: messages.regenerateCertificates.defaultMessage }); + expect(button).toBeDisabled(); + }); + + it('should enable regenerate button when filter is "received"', async () => { + const user = userEvent.setup(); + renderWithProviders(); + + const dropdown = screen.getByText(messages.allLearners.defaultMessage); + await user.click(dropdown); + + const receivedOption = screen.getByText(messages.received.defaultMessage); + await user.click(receivedOption); + + const button = screen.getByRole('button', { name: messages.regenerateCertificates.defaultMessage }); + expect(button).not.toBeDisabled(); + }); + + it('should disable regenerate button when count is 0', () => { + mockUseIssuedCertificates.mockReturnValue({ + data: { results: [], count: 0 }, + isLoading: false, + error: null, + } as any); + + renderWithProviders(); + + const button = screen.getByRole('button', { name: messages.regenerateCertificates.defaultMessage }); + expect(button).toBeDisabled(); + }); + + it('should open modal when regenerate button clicked', async () => { + const user = userEvent.setup(); + renderWithProviders(); + + const dropdown = screen.getByText(messages.allLearners.defaultMessage); + await user.click(dropdown); + + const receivedOption = screen.getByText(messages.received.defaultMessage); + await user.click(receivedOption); + + const buttons = screen.getAllByRole('button', { name: messages.regenerateCertificates.defaultMessage }); + await user.click(buttons[0]); + + expect(screen.getByText(messages.regenerateAllLearnersMessage.defaultMessage)).toBeInTheDocument(); + }); + + it('should display correct modal content for "received" filter', async () => { + const user = userEvent.setup(); + renderWithProviders(); + + const dropdown = screen.getByText(messages.allLearners.defaultMessage); + await user.click(dropdown); + + const receivedOption = screen.getByText(messages.received.defaultMessage); + await user.click(receivedOption); + + const button = screen.getByRole('button', { name: messages.regenerateCertificates.defaultMessage }); + await user.click(button); + + expect(screen.getByText(messages.regenerateAllLearnersMessage.defaultMessage)).toBeInTheDocument(); + }); + + it('should display correct modal content for "granted_exceptions" filter', async () => { + const user = userEvent.setup(); + renderWithProviders(); + + const dropdown = screen.getByText(messages.allLearners.defaultMessage); + await user.click(dropdown); + + const exceptionsOption = screen.getByText(messages.grantedExceptions.defaultMessage); + await user.click(exceptionsOption); + + const button = screen.getByRole('button', { name: messages.regenerateCertificates.defaultMessage }); + await user.click(button); + + expect(screen.getByText(messages.generateCertificatesTitle.defaultMessage)).toBeInTheDocument(); + expect(screen.getByText(messages.generateExceptionsMessage.defaultMessage)).toBeInTheDocument(); + }); + + it('should close modal when cancel is clicked', async () => { + const user = userEvent.setup(); + renderWithProviders(); + + const dropdown = screen.getByText(messages.allLearners.defaultMessage); + await user.click(dropdown); + + const receivedOption = screen.getByText(messages.received.defaultMessage); + await user.click(receivedOption); + + const regenerateButtons = screen.getAllByRole('button', { name: messages.regenerateCertificates.defaultMessage }); + await user.click(regenerateButtons[0]); + + const cancelButton = screen.getByRole('button', { name: messages.cancel.defaultMessage }); + await user.click(cancelButton); + + await waitFor(() => { + expect(screen.queryByText(messages.regenerateAllLearnersMessage.defaultMessage)).not.toBeInTheDocument(); + }); + }); + + it('should call regenerate mutation with correct params for "received" filter', async () => { + const user = userEvent.setup(); + renderWithProviders(); + + const dropdown = screen.getByText(messages.allLearners.defaultMessage); + await user.click(dropdown); + + const receivedOption = screen.getByText(messages.received.defaultMessage); + await user.click(receivedOption); + + const regenerateButton = screen.getByRole('button', { name: messages.regenerateCertificates.defaultMessage }); + await user.click(regenerateButton); + + const confirmButton = screen.getByRole('button', { name: messages.regenerate.defaultMessage }); + await user.click(confirmButton); + + expect(mockMutate).toHaveBeenCalledWith( + { courseId: 'course-123', params: { statuses: ['downloadable'] } }, + expect.objectContaining({ + onSuccess: expect.any(Function), + onError: expect.any(Function), + }) + ); + }); + + it('should call regenerate mutation with correct params for "not_received" filter', async () => { + const user = userEvent.setup(); + renderWithProviders(); + + const dropdown = screen.getByText(messages.allLearners.defaultMessage); + await user.click(dropdown); + + const notReceivedOption = screen.getByText(messages.notReceived.defaultMessage); + await user.click(notReceivedOption); + + const regenerateButton = screen.getByRole('button', { name: messages.regenerateCertificates.defaultMessage }); + await user.click(regenerateButton); + + const confirmButton = screen.getByRole('button', { name: messages.regenerate.defaultMessage }); + await user.click(confirmButton); + + expect(mockMutate).toHaveBeenCalledWith( + { courseId: 'course-123', params: { statuses: ['notpassing', 'unavailable'] } }, + expect.objectContaining({ + onSuccess: expect.any(Function), + onError: expect.any(Function), + }) + ); + }); + + it('should call regenerate mutation with correct params for "granted_exceptions" filter', async () => { + const user = userEvent.setup(); + renderWithProviders(); + + const dropdown = screen.getByText(messages.allLearners.defaultMessage); + await user.click(dropdown); + + const exceptionsOption = screen.getByText(messages.grantedExceptions.defaultMessage); + await user.click(exceptionsOption); + + const regenerateButton = screen.getByRole('button', { name: messages.regenerateCertificates.defaultMessage }); + await user.click(regenerateButton); + + const confirmButton = screen.getByRole('button', { name: messages.generate.defaultMessage }); + await user.click(confirmButton); + + expect(mockMutate).toHaveBeenCalledWith( + { courseId: 'course-123', params: { studentSet: 'allowlisted' } }, + expect.objectContaining({ + onSuccess: expect.any(Function), + onError: expect.any(Function), + }) + ); + }); + + it('should show success toast on successful regeneration', async () => { + const user = userEvent.setup(); + let capturedCallbacks: any; + + mockMutate.mockImplementation((_, callbacks) => { + capturedCallbacks = callbacks; + }); + + renderWithProviders(); + + const dropdown = screen.getByText(messages.allLearners.defaultMessage); + await user.click(dropdown); + + const receivedOption = screen.getByText(messages.received.defaultMessage); + await user.click(receivedOption); + + const regenerateButton = screen.getByRole('button', { name: messages.regenerateCertificates.defaultMessage }); + await user.click(regenerateButton); + + const confirmButton = screen.getByRole('button', { name: messages.regenerate.defaultMessage }); + await user.click(confirmButton); + + capturedCallbacks.onSuccess(); + + await waitFor(() => { + expect(screen.getByText(messages.regenerateSuccess.defaultMessage)).toBeInTheDocument(); + }); + }); + + it('should show error modal on regeneration failure', async () => { + const user = userEvent.setup(); + let capturedCallbacks: any; + + mockMutate.mockImplementation((_, callbacks) => { + capturedCallbacks = callbacks; + }); + + renderWithProviders(); + + const dropdown = screen.getByText(messages.allLearners.defaultMessage); + await user.click(dropdown); + + const receivedOption = screen.getByText(messages.received.defaultMessage); + await user.click(receivedOption); + + const regenerateButton = screen.getByRole('button', { name: messages.regenerateCertificates.defaultMessage }); + await user.click(regenerateButton); + + const confirmButton = screen.getByRole('button', { name: messages.regenerate.defaultMessage }); + await user.click(confirmButton); + + const error = { response: { data: { error: 'Custom error message' } } }; + capturedCallbacks.onError(error); + + await waitFor(() => { + expect(screen.getByText('Custom error message')).toBeInTheDocument(); + }); + }); + + it('should handle search filter', async () => { + const user = userEvent.setup(); + renderWithProviders(); + + const searchInput = screen.getByPlaceholderText(messages.searchPlaceholder.defaultMessage); + await user.type(searchInput, 'john'); + + await waitFor(() => { + const calls = mockUseIssuedCertificates.mock.calls; + const lastCall = calls[calls.length - 1]; + expect(lastCall[2]).toBe('john'); + }, { timeout: 500 }); + }); + + it('should render error state', () => { + mockUseIssuedCertificates.mockReturnValue({ + data: undefined, + isLoading: false, + error: new Error('Failed to load'), + } as any); + + renderWithProviders(); + + expect(screen.getByText('Error loading certificates')).toBeInTheDocument(); + }); + + it('should render loading state', () => { + mockUseIssuedCertificates.mockReturnValue({ + data: undefined, + isLoading: true, + error: null, + } as any); + + renderWithProviders(); + + expect(screen.getByRole('button', { name: messages.regenerateCertificates.defaultMessage })).toBeInTheDocument(); + }); + + it('should disable regenerate button while regenerating', () => { + mockUseRegenerateCertificatesMutation.mockReturnValue({ + mutate: mockMutate, + isPending: true, + } as any); + + renderWithProviders(); + + const button = screen.getByRole('button', { name: messages.regenerateCertificates.defaultMessage }); + expect(button).toBeDisabled(); + }); + + it('should render special case cells with dash when value is null', () => { + renderWithProviders(); + + const dashCells = screen.getAllByText('—'); + expect(dashCells.length).toBeGreaterThan(0); + }); + + it('should reset to page 0 when search filter changes', async () => { + const user = userEvent.setup(); + renderWithProviders(); + + const searchInput = screen.getByPlaceholderText(messages.searchPlaceholder.defaultMessage); + await user.type(searchInput, 'test'); + + await waitFor(() => { + const calls = mockUseIssuedCertificates.mock.calls; + const hasExpectedCall = calls.some(call => + call[0] === 'course-123' + && call[1].page === 0 + && call[1].pageSize === 10 + && call[2] === 'test' + && call[3] === 'all' + ); + expect(hasExpectedCall).toBe(true); + }, { timeout: 1000 }); + }); + + it('should handle error without response data', async () => { + const user = userEvent.setup(); + let capturedCallbacks: any; + + mockMutate.mockImplementation((_, callbacks) => { + capturedCallbacks = callbacks; + return undefined; + }); + + renderWithProviders(); + + const dropdown = screen.getByText(messages.allLearners.defaultMessage); + await user.click(dropdown); + + const receivedOption = screen.getByText(messages.received.defaultMessage); + await user.click(receivedOption); + + const regenerateButton = screen.getByRole('button', { name: messages.regenerateCertificates.defaultMessage }); + await user.click(regenerateButton); + + const confirmButton = screen.getByRole('button', { name: messages.regenerate.defaultMessage }); + await user.click(confirmButton); + + // Wait for mutation to be called + await waitFor(() => { + expect(capturedCallbacks).toBeDefined(); + }); + + const error = new Error('Network error'); + capturedCallbacks.onError(error); + + await waitFor(() => { + expect(screen.getByText('Network error')).toBeInTheDocument(); + }); + }); + + it('should display correct confirmation count', async () => { + const user = userEvent.setup(); + renderWithProviders(); + + const dropdown = screen.getByText(messages.allLearners.defaultMessage); + await user.click(dropdown); + + const receivedOption = screen.getByText(messages.received.defaultMessage); + await user.click(receivedOption); + + const regenerateButton = screen.getByRole('button', { name: messages.regenerateCertificates.defaultMessage }); + await user.click(regenerateButton); + + expect(screen.getByText(/Regenerate certificates for 2 learners?/)).toBeInTheDocument(); + }); +}); diff --git a/src/certificates/components/IssuedCertificates.tsx b/src/certificates/components/IssuedCertificates.tsx new file mode 100644 index 00000000..10e2a53b --- /dev/null +++ b/src/certificates/components/IssuedCertificates.tsx @@ -0,0 +1,325 @@ +import { useState } from 'react'; +import { useParams } from 'react-router-dom'; +import { useIntl } from '@openedx/frontend-base'; +import { + Button, + DataTable, + Form, + Dropdown, + ModalDialog, +} from '@openedx/paragon'; +import { useIssuedCertificates, useRegenerateCertificatesMutation } from '../data/apiHook'; +import { useAlert } from '@src/providers/AlertProvider'; +import messages from '../messages'; +import { CertificateFilter } from '../types'; +import { APIError } from '@src/types'; +import { useDebouncedFilter } from '@src/hooks/useDebouncedFilter'; +import { FormControl, Icon } from '@openedx/paragon'; +import { Search } from '@openedx/paragon/icons'; + +const SearchFilter = ({ column: { filterValue, setFilter } }: { column: { filterValue: string, setFilter: (value: string) => void } }) => { + const intl = useIntl(); + const { inputValue, handleChange } = useDebouncedFilter({ + filterValue, + setFilter, + delay: 400, + }); + + return ( + ) => handleChange(e.target.value)} + placeholder={intl.formatMessage(messages.searchPlaceholder)} + trailingElement={} + value={inputValue} + /> + ); +}; + +const FilterDropdownFilter = ({ column: { filterValue, setFilter } }: { column: { filterValue: string, setFilter: (value: string) => void } }) => { + const intl = useIntl(); + + const filterOptions = [ + { value: 'all', label: intl.formatMessage(messages.allLearners) }, + { value: 'received', label: intl.formatMessage(messages.received) }, + { value: 'not_received', label: intl.formatMessage(messages.notReceived) }, + { value: 'audit_passing', label: intl.formatMessage(messages.auditPassing) }, + { value: 'audit_not_passing', label: intl.formatMessage(messages.auditNotPassing) }, + { value: 'error', label: intl.formatMessage(messages.errorState) }, + { value: 'granted_exceptions', label: intl.formatMessage(messages.grantedExceptions) }, + { value: 'invalidated', label: intl.formatMessage(messages.invalidated) }, + ]; + + return ( + + + + {filterOptions.find(opt => opt.value === (filterValue || 'all'))?.label} + + + {filterOptions.map(option => ( + setFilter(option.value)} + > + {option.label} + + ))} + + + + ); +}; + +const IssuedCertificates = () => { + const intl = useIntl(); + const { courseId = '' } = useParams<{ courseId: string }>(); + const [filters, setFilters] = useState<{ page: number, search: string, filter: CertificateFilter }>({ + page: 0, + search: '', + filter: 'all', + }); + const [isRegenerateModalOpen, setIsRegenerateModalOpen] = useState(false); + const pageSize = 10; + const { showToast, showModal, removeAlert } = useAlert(); + const { mutate: regenerateMutation, isPending: isRegenerating } = useRegenerateCertificatesMutation(); + + const { data, isLoading, error } = useIssuedCertificates( + courseId, + { page: filters.page, pageSize }, + filters.search, + filters.filter + ); + + const columns = [ + { + Header: intl.formatMessage(messages.username), + accessor: 'username', + Filter: SearchFilter, + }, + { + Header: intl.formatMessage(messages.email), + accessor: 'email', + Filter: FilterDropdownFilter, + }, + { + Header: intl.formatMessage(messages.enrollmentTrack), + accessor: 'enrollmentTrack', + disableFilters: true, + }, + { + Header: intl.formatMessage(messages.certificateStatus), + accessor: 'certificateStatus', + disableFilters: true, + }, + { + Header: intl.formatMessage(messages.specialCase), + accessor: 'specialCase', + Cell: ({ value }) => value || '—', + disableFilters: true, + }, + { + Header: intl.formatMessage(messages.exceptionGranted), + accessor: 'exceptionGranted', + Cell: ({ value }) => value || '—', + disableFilters: true, + }, + { + Header: intl.formatMessage(messages.exceptionNotes), + accessor: 'exceptionNotes', + Cell: ({ value }) => value || '—', + disableFilters: true, + }, + { + Header: intl.formatMessage(messages.invalidatedBy), + accessor: 'invalidatedBy', + Cell: ({ value }) => value || '—', + disableFilters: true, + }, + { + Header: intl.formatMessage(messages.invalidationDate), + accessor: 'invalidationDate', + Cell: ({ value }) => value || '—', + disableFilters: true, + }, + ]; + + const handleFetchData = (data: { pageIndex: number, filters: { id: string, value: string }[] }) => { + const searchFilter = data.filters.find((filter) => filter.id === 'username'); + const newSearch = searchFilter ? searchFilter.value : ''; + const filterFilter = data.filters.find((filter) => filter.id === 'email'); + const newFilter = filterFilter ? filterFilter.value as CertificateFilter : 'all'; + + const filterChanged = newSearch !== filters.search || newFilter !== filters.filter; + const pageChanged = data.pageIndex !== filters.page; + + // If filters changed, reset to page 0 + if (filterChanged) { + setFilters({ page: 0, search: newSearch, filter: newFilter }); + } else if (pageChanged) { + // If only page changed (filters didn't change), update page + setFilters({ page: data.pageIndex, search: newSearch, filter: newFilter }); + } + }; + + // Determine if regenerate button should be disabled + const isRegenerateDisabled = filters.filter === 'all' || filters.filter === 'invalidated' || (data?.count || 0) === 0; + + // Get modal content based on filter + const getModalContent = () => { + const filterMessages = { + received: intl.formatMessage(messages.regenerateAllLearnersMessage), + not_received: intl.formatMessage(messages.regenerateNotReceivedMessage), + audit_passing: intl.formatMessage(messages.regenerateAuditPassingMessage), + audit_not_passing: intl.formatMessage(messages.regenerateAuditNotPassingMessage), + error: intl.formatMessage(messages.regenerateErrorMessage), + granted_exceptions: intl.formatMessage(messages.generateExceptionsMessage), + }; + return filterMessages[filters.filter] || intl.formatMessage(messages.regenerateAllLearnersMessage); + }; + + const handleOpenRegenerateModal = () => { + if (isRegenerateDisabled) return; + setIsRegenerateModalOpen(true); + }; + + const handleConfirmRegenerate = () => { + if (!courseId) return; + + // Map filter to API parameters + const params: any = {}; + + if (filters.filter === 'granted_exceptions') { + params.studentSet = 'allowlisted'; + } else if (filters.filter === 'received') { + params.statuses = ['downloadable']; + } else if (filters.filter === 'not_received') { + params.statuses = ['notpassing', 'unavailable']; + } else if (filters.filter === 'audit_passing') { + params.statuses = ['audit_passing']; + } else if (filters.filter === 'audit_not_passing') { + params.statuses = ['audit_notpassing']; + } else if (filters.filter === 'error') { + params.statuses = ['error']; + } + + regenerateMutation( + { courseId, params }, + { + onSuccess: () => { + setIsRegenerateModalOpen(false); + showToast(intl.formatMessage(messages.regenerateSuccess)); + }, + onError: (err: APIError | Error) => { + setIsRegenerateModalOpen(false); + const errorMessage = 'response' in err + ? err.response?.data?.error || intl.formatMessage(messages.regenerateError) + : err.message; + showModal({ + confirmText: intl.formatMessage(messages.close), + message: errorMessage, + variant: 'danger', + onConfirm: (id) => removeAlert(id), + }); + }, + } + ); + }; + + return ( +
+ setIsRegenerateModalOpen(false)} + isOverflowVisible={false} + > + + + {intl.formatMessage( + filters.filter === 'granted_exceptions' + ? messages.generateCertificatesTitle + : messages.regenerateCertificatesTitle + )} + + + +

{getModalContent()}

+

+ {intl.formatMessage(messages.regenerateConfirmation, { + action: filters.filter === 'granted_exceptions' ? 'Generate' : 'Regenerate', + number: data?.count || 0, + })} +

+
+ + + + +
+ + {error ? ( +
Error loading certificates
+ ) : ( + null} + > +
+ + +
+ + + +
+ )} +
+ ); +}; + +export default IssuedCertificates; diff --git a/src/certificates/data/api.test.ts b/src/certificates/data/api.test.ts new file mode 100644 index 00000000..868017a9 --- /dev/null +++ b/src/certificates/data/api.test.ts @@ -0,0 +1,194 @@ +import { getIssuedCertificates, getCertificateGenerationHistory, regenerateCertificates, getCertificateConfig } from './api'; +import { camelCaseObject, getAuthenticatedHttpClient, snakeCaseObject } from '@openedx/frontend-base'; +import { getApiBaseUrl } from '@src/data/api'; + +jest.mock('@openedx/frontend-base'); +jest.mock('@src/data/api'); + +const mockGet = jest.fn(); +const mockPost = jest.fn(); +const mockGetAuthenticatedHttpClient = getAuthenticatedHttpClient as jest.MockedFunction; +const mockGetApiBaseUrl = getApiBaseUrl as jest.MockedFunction; +const mockCamelCaseObject = camelCaseObject as jest.MockedFunction; +const mockSnakeCaseObject = snakeCaseObject as jest.MockedFunction; + +describe('certificates api', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockGetAuthenticatedHttpClient.mockReturnValue({ + get: mockGet, + post: mockPost, + } as any); + mockGetApiBaseUrl.mockReturnValue('http://localhost:8000'); + mockCamelCaseObject.mockImplementation((data) => data); + mockSnakeCaseObject.mockImplementation((data) => data); + }); + + describe('getIssuedCertificates', () => { + it('should fetch issued certificates with pagination', async () => { + const mockData = { results: [], count: 0 }; + mockGet.mockResolvedValue({ data: mockData }); + + const result = await getIssuedCertificates('course-123', { page: 0, pageSize: 10 }); + + expect(mockGet).toHaveBeenCalledWith( + 'http://localhost:8000/api/instructor/v2/courses/course-123/certificates/issued?page=1&page_size=10' + ); + expect(mockCamelCaseObject).toHaveBeenCalledWith(mockData); + expect(result).toEqual(mockData); + }); + + it('should include search parameter when provided', async () => { + const mockData = { results: [], count: 0 }; + mockGet.mockResolvedValue({ data: mockData }); + + await getIssuedCertificates('course-123', { page: 0, pageSize: 10 }, 'john'); + + expect(mockGet).toHaveBeenCalledWith( + 'http://localhost:8000/api/instructor/v2/courses/course-123/certificates/issued?page=1&page_size=10&search=john' + ); + }); + + it('should include filter parameter when provided and not "all"', async () => { + const mockData = { results: [], count: 0 }; + mockGet.mockResolvedValue({ data: mockData }); + + await getIssuedCertificates('course-123', { page: 0, pageSize: 10 }, undefined, 'received'); + + expect(mockGet).toHaveBeenCalledWith( + 'http://localhost:8000/api/instructor/v2/courses/course-123/certificates/issued?page=1&page_size=10&filter=received' + ); + }); + + it('should not include filter parameter when filter is "all"', async () => { + const mockData = { results: [], count: 0 }; + mockGet.mockResolvedValue({ data: mockData }); + + await getIssuedCertificates('course-123', { page: 0, pageSize: 10 }, undefined, 'all'); + + expect(mockGet).toHaveBeenCalledWith( + 'http://localhost:8000/api/instructor/v2/courses/course-123/certificates/issued?page=1&page_size=10' + ); + }); + + it('should include both search and filter parameters', async () => { + const mockData = { results: [], count: 0 }; + mockGet.mockResolvedValue({ data: mockData }); + + await getIssuedCertificates('course-123', { page: 0, pageSize: 10 }, 'john', 'received'); + + expect(mockGet).toHaveBeenCalledWith( + 'http://localhost:8000/api/instructor/v2/courses/course-123/certificates/issued?page=1&page_size=10&search=john&filter=received' + ); + }); + + it('should convert page index to 1-based for API', async () => { + const mockData = { results: [], count: 0 }; + mockGet.mockResolvedValue({ data: mockData }); + + await getIssuedCertificates('course-123', { page: 2, pageSize: 20 }); + + expect(mockGet).toHaveBeenCalledWith( + 'http://localhost:8000/api/instructor/v2/courses/course-123/certificates/issued?page=3&page_size=20' + ); + }); + }); + + describe('getCertificateGenerationHistory', () => { + it('should fetch certificate generation history with pagination', async () => { + const mockData = { results: [], count: 0 }; + mockGet.mockResolvedValue({ data: mockData }); + + const result = await getCertificateGenerationHistory('course-456', { page: 0, pageSize: 20 }); + + expect(mockGet).toHaveBeenCalledWith( + 'http://localhost:8000/api/instructor/v2/courses/course-456/certificates/generation_history?page=1&page_size=20' + ); + expect(mockCamelCaseObject).toHaveBeenCalledWith(mockData); + expect(result).toEqual(mockData); + }); + + it('should convert page index to 1-based for API', async () => { + const mockData = { results: [], count: 0 }; + mockGet.mockResolvedValue({ data: mockData }); + + await getCertificateGenerationHistory('course-456', { page: 3, pageSize: 20 }); + + expect(mockGet).toHaveBeenCalledWith( + 'http://localhost:8000/api/instructor/v2/courses/course-456/certificates/generation_history?page=4&page_size=20' + ); + }); + }); + + describe('regenerateCertificates', () => { + it('should post regenerate request with snake_case params', async () => { + const mockData = { success: true }; + mockPost.mockResolvedValue({ data: mockData }); + mockSnakeCaseObject.mockReturnValue({ statuses: ['downloadable'] }); + + const params = { statuses: ['downloadable'] }; + const result = await regenerateCertificates('course-789', params); + + expect(mockSnakeCaseObject).toHaveBeenCalledWith(params); + expect(mockPost).toHaveBeenCalledWith( + 'http://localhost:8000/api/instructor/v2/courses/course-789/certificates/regenerate', + { statuses: ['downloadable'] } + ); + expect(mockCamelCaseObject).toHaveBeenCalledWith(mockData); + expect(result).toEqual(mockData); + }); + + it('should handle studentSet parameter', async () => { + const mockData = { success: true }; + mockPost.mockResolvedValue({ data: mockData }); + mockSnakeCaseObject.mockReturnValue({ student_set: 'allowlisted' }); + + const params = { studentSet: 'allowlisted' as const }; + await regenerateCertificates('course-789', params); + + expect(mockSnakeCaseObject).toHaveBeenCalledWith(params); + expect(mockPost).toHaveBeenCalledWith( + 'http://localhost:8000/api/instructor/v2/courses/course-789/certificates/regenerate', + { student_set: 'allowlisted' } + ); + }); + + it('should handle empty params', async () => { + const mockData = { success: true }; + mockPost.mockResolvedValue({ data: mockData }); + mockSnakeCaseObject.mockReturnValue({}); + + await regenerateCertificates('course-789', {}); + + expect(mockSnakeCaseObject).toHaveBeenCalledWith({}); + expect(mockPost).toHaveBeenCalledWith( + 'http://localhost:8000/api/instructor/v2/courses/course-789/certificates/regenerate', + {} + ); + }); + }); + + describe('getCertificateConfig', () => { + it('should fetch certificate config', async () => { + const mockData = { enabled: true }; + mockGet.mockResolvedValue({ data: mockData }); + + const result = await getCertificateConfig('course-999'); + + expect(mockGet).toHaveBeenCalledWith( + 'http://localhost:8000/api/instructor/v2/courses/course-999/certificates/config' + ); + expect(mockCamelCaseObject).toHaveBeenCalledWith(mockData); + expect(result).toEqual(mockData); + }); + + it('should handle disabled certificates', async () => { + const mockData = { enabled: false }; + mockGet.mockResolvedValue({ data: mockData }); + + const result = await getCertificateConfig('course-999'); + + expect(result).toEqual({ enabled: false }); + }); + }); +}); diff --git a/src/certificates/data/api.ts b/src/certificates/data/api.ts new file mode 100644 index 00000000..d87dc0da --- /dev/null +++ b/src/certificates/data/api.ts @@ -0,0 +1,63 @@ +import { camelCaseObject, getAuthenticatedHttpClient, snakeCaseObject } from '@openedx/frontend-base'; +import { getApiBaseUrl } from '../../data/api'; +import { + IssuedCertificate, + CertificateGenerationHistory, + RegenerateCertificatesParams, + CertificateFilter +} from '../types'; +import { DataList, PaginationQueryKeys } from '@src/types'; + +export const getIssuedCertificates = async ( + courseId: string, + pagination: PaginationQueryKeys, + search?: string, + filter?: CertificateFilter +): Promise> => { + const params = new URLSearchParams({ + page: String(pagination.page + 1), + page_size: String(pagination.pageSize), + }); + + if (search) { + params.append('search', search); + } + + if (filter && filter !== 'all') { + params.append('filter', filter); + } + + const { data } = await getAuthenticatedHttpClient().get( + `${getApiBaseUrl()}/api/instructor/v2/courses/${courseId}/certificates/issued?${params.toString()}` + ); + return camelCaseObject(data); +}; + +export const getCertificateGenerationHistory = async ( + courseId: string, + pagination: PaginationQueryKeys +): Promise> => { + const { data } = await getAuthenticatedHttpClient().get( + `${getApiBaseUrl()}/api/instructor/v2/courses/${courseId}/certificates/generation_history?page=${pagination.page + 1}&page_size=${pagination.pageSize}` + ); + return camelCaseObject(data); +}; + +export const regenerateCertificates = async ( + courseId: string, + params: RegenerateCertificatesParams +) => { + const snakeCaseData = snakeCaseObject(params); + const { data } = await getAuthenticatedHttpClient().post( + `${getApiBaseUrl()}/api/instructor/v2/courses/${courseId}/certificates/regenerate`, + snakeCaseData + ); + return camelCaseObject(data); +}; + +export const getCertificateConfig = async (courseId: string): Promise<{ enabled: boolean }> => { + const { data } = await getAuthenticatedHttpClient().get( + `${getApiBaseUrl()}/api/instructor/v2/courses/${courseId}/certificates/config` + ); + return camelCaseObject(data); +}; diff --git a/src/certificates/data/apiHook.test.tsx b/src/certificates/data/apiHook.test.tsx new file mode 100644 index 00000000..de3f125c --- /dev/null +++ b/src/certificates/data/apiHook.test.tsx @@ -0,0 +1,301 @@ +import { renderHook, waitFor } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { useIssuedCertificates, useCertificateGenerationHistory, useRegenerateCertificatesMutation, useCertificateConfig } from './apiHook'; +import { certificatesQueryKeys } from './queryKeys'; +import { getIssuedCertificates, getCertificateGenerationHistory, regenerateCertificates, getCertificateConfig } from './api'; +import { ReactNode } from 'react'; + +jest.mock('./api'); + +const mockGetIssuedCertificates = getIssuedCertificates as jest.MockedFunction; +const mockGetCertificateGenerationHistory = getCertificateGenerationHistory as jest.MockedFunction; +const mockRegenerateCertificates = regenerateCertificates as jest.MockedFunction; +const mockGetCertificateConfig = getCertificateConfig as jest.MockedFunction; + +const createWrapper = () => { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false }, + }, + }); + const WrappedComponent = ({ children }: { children: ReactNode }) => ( + {children} + ); + return WrappedComponent; +}; + +describe('certificates apiHook', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('useIssuedCertificates', () => { + it('should fetch issued certificates successfully', async () => { + const mockData = { results: [], count: 0, next: null, previous: null, numPages: 0 }; + mockGetIssuedCertificates.mockResolvedValue(mockData); + + const { result } = renderHook( + () => useIssuedCertificates('course-123', { page: 0, pageSize: 10 }), + { wrapper: createWrapper() } + ); + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true); + }); + + expect(mockGetIssuedCertificates).toHaveBeenCalledWith('course-123', { page: 0, pageSize: 10 }, undefined, undefined); + expect(result.current.data).toEqual(mockData); + }); + + it('should fetch with search parameter', async () => { + const mockData = { results: [], count: 0, next: null, previous: null, numPages: 0 }; + mockGetIssuedCertificates.mockResolvedValue(mockData); + + const { result } = renderHook( + () => useIssuedCertificates('course-123', { page: 0, pageSize: 10 }, 'john'), + { wrapper: createWrapper() } + ); + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true); + }); + + expect(mockGetIssuedCertificates).toHaveBeenCalledWith('course-123', { page: 0, pageSize: 10 }, 'john', undefined); + }); + + it('should fetch with filter parameter', async () => { + const mockData = { results: [], count: 0, next: null, previous: null, numPages: 0 }; + mockGetIssuedCertificates.mockResolvedValue(mockData); + + const { result } = renderHook( + () => useIssuedCertificates('course-123', { page: 0, pageSize: 10 }, undefined, 'received'), + { wrapper: createWrapper() } + ); + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true); + }); + + expect(mockGetIssuedCertificates).toHaveBeenCalledWith('course-123', { page: 0, pageSize: 10 }, undefined, 'received'); + }); + + it('should handle fetch error', async () => { + const apiError = new Error('API Error'); + mockGetIssuedCertificates.mockRejectedValue(apiError); + + const { result } = renderHook( + () => useIssuedCertificates('course-123', { page: 0, pageSize: 10 }), + { wrapper: createWrapper() } + ); + + await waitFor(() => { + expect(result.current.isError).toBe(true); + }); + + expect(result.current.error).toEqual(apiError); + }); + + it('should use correct query key', async () => { + const mockData = { results: [], count: 0, next: null, previous: null, numPages: 0 }; + mockGetIssuedCertificates.mockResolvedValue(mockData); + + const { result } = renderHook( + () => useIssuedCertificates('course-123', { page: 1, pageSize: 20 }, 'test', 'received'), + { wrapper: createWrapper() } + ); + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true); + }); + + expect(mockGetIssuedCertificates).toHaveBeenCalledWith('course-123', { page: 1, pageSize: 20 }, 'test', 'received'); + }); + }); + + describe('useCertificateGenerationHistory', () => { + it('should fetch generation history successfully', async () => { + const mockData = { results: [], count: 0, next: null, previous: null, numPages: 0 }; + mockGetCertificateGenerationHistory.mockResolvedValue(mockData); + + const { result } = renderHook( + () => useCertificateGenerationHistory('course-456', { page: 0, pageSize: 20 }), + { wrapper: createWrapper() } + ); + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true); + }); + + expect(mockGetCertificateGenerationHistory).toHaveBeenCalledWith('course-456', { page: 0, pageSize: 20 }); + expect(result.current.data).toEqual(mockData); + }); + + it('should handle fetch error', async () => { + const apiError = new Error('History fetch failed'); + mockGetCertificateGenerationHistory.mockRejectedValue(apiError); + + const { result } = renderHook( + () => useCertificateGenerationHistory('course-456', { page: 0, pageSize: 20 }), + { wrapper: createWrapper() } + ); + + await waitFor(() => { + expect(result.current.isError).toBe(true); + }); + + expect(result.current.error).toEqual(apiError); + }); + }); + + describe('useRegenerateCertificatesMutation', () => { + it('should regenerate certificates successfully', async () => { + const mockResponse = { success: true }; + mockRegenerateCertificates.mockResolvedValue(mockResponse); + + const { result } = renderHook( + () => useRegenerateCertificatesMutation(), + { wrapper: createWrapper() } + ); + + result.current.mutate({ + courseId: 'course-789', + params: { statuses: ['downloadable'] }, + }); + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true); + }); + + expect(mockRegenerateCertificates).toHaveBeenCalledWith('course-789', { statuses: ['downloadable'] }); + expect(result.current.data).toEqual(mockResponse); + }); + + it('should regenerate with studentSet parameter', async () => { + const mockResponse = { success: true }; + mockRegenerateCertificates.mockResolvedValue(mockResponse); + + const { result } = renderHook( + () => useRegenerateCertificatesMutation(), + { wrapper: createWrapper() } + ); + + result.current.mutate({ + courseId: 'course-789', + params: { studentSet: 'allowlisted' }, + }); + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true); + }); + + expect(mockRegenerateCertificates).toHaveBeenCalledWith('course-789', { studentSet: 'allowlisted' }); + }); + + it('should handle mutation error', async () => { + mockRegenerateCertificates.mockRejectedValue(new Error('Regeneration failed')); + + const { result } = renderHook( + () => useRegenerateCertificatesMutation(), + { wrapper: createWrapper() } + ); + + result.current.mutate({ + courseId: 'course-789', + params: {}, + }); + + await waitFor(() => { + expect(result.current.isError).toBe(true); + }); + + expect(result.current.error).toEqual(new Error('Regeneration failed')); + }); + + it('should invalidate queries on success', async () => { + const mockResponse = { success: true }; + mockRegenerateCertificates.mockResolvedValue(mockResponse); + + const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false }, + }, + }); + + const invalidateSpy = jest.spyOn(queryClient, 'invalidateQueries'); + + const Wrapper = ({ children }: { children: ReactNode }) => ( + {children} + ); + + const { result } = renderHook( + () => useRegenerateCertificatesMutation(), + { wrapper: Wrapper } + ); + + result.current.mutate({ + courseId: 'course-789', + params: {}, + }); + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true); + }); + + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: certificatesQueryKeys.lists() }); + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: certificatesQueryKeys.all }); + }); + }); + + describe('useCertificateConfig', () => { + it('should fetch certificate config successfully', async () => { + const mockData = { enabled: true }; + mockGetCertificateConfig.mockResolvedValue(mockData); + + const { result } = renderHook( + () => useCertificateConfig('course-999'), + { wrapper: createWrapper() } + ); + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true); + }); + + expect(mockGetCertificateConfig).toHaveBeenCalledWith('course-999'); + expect(result.current.data).toEqual(mockData); + }); + + it('should handle disabled certificates', async () => { + const mockData = { enabled: false }; + mockGetCertificateConfig.mockResolvedValue(mockData); + + const { result } = renderHook( + () => useCertificateConfig('course-999'), + { wrapper: createWrapper() } + ); + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true); + }); + + expect(result.current.data).toEqual({ enabled: false }); + }); + + it('should handle fetch error', async () => { + const apiError = { response: { status: 404 } }; + mockGetCertificateConfig.mockRejectedValue(apiError); + + const { result } = renderHook( + () => useCertificateConfig('course-999'), + { wrapper: createWrapper() } + ); + + await waitFor(() => { + expect(result.current.isError).toBe(true); + }); + + expect(result.current.error).toEqual(apiError); + }); + }); +}); diff --git a/src/certificates/data/apiHook.ts b/src/certificates/data/apiHook.ts new file mode 100644 index 00000000..4a472caa --- /dev/null +++ b/src/certificates/data/apiHook.ts @@ -0,0 +1,47 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { PaginationQueryKeys } from '@src/types'; +import { getIssuedCertificates, getCertificateGenerationHistory, regenerateCertificates, getCertificateConfig } from './api'; +import { certificatesQueryKeys } from './queryKeys'; +import { CertificateFilter, RegenerateCertificatesParams } from '../types'; + +export const useIssuedCertificates = ( + courseId: string, + pagination: PaginationQueryKeys, + search?: string, + filter?: CertificateFilter +) => { + return useQuery({ + queryKey: certificatesQueryKeys.list(courseId, pagination, search, filter), + queryFn: () => getIssuedCertificates(courseId, pagination, search, filter), + }); +}; + +export const useCertificateGenerationHistory = ( + courseId: string, + pagination: PaginationQueryKeys +) => { + return useQuery({ + queryKey: certificatesQueryKeys.history(courseId, pagination), + queryFn: () => getCertificateGenerationHistory(courseId, pagination), + }); +}; + +export const useRegenerateCertificatesMutation = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ courseId, params }: { courseId: string, params: RegenerateCertificatesParams }) => + regenerateCertificates(courseId, params), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: certificatesQueryKeys.lists() }); + queryClient.invalidateQueries({ queryKey: certificatesQueryKeys.all }); + }, + }); +}; + +export const useCertificateConfig = (courseId: string) => { + return useQuery({ + queryKey: certificatesQueryKeys.config(courseId), + queryFn: () => getCertificateConfig(courseId), + }); +}; diff --git a/src/certificates/data/queryKeys.test.ts b/src/certificates/data/queryKeys.test.ts new file mode 100644 index 00000000..999b01dc --- /dev/null +++ b/src/certificates/data/queryKeys.test.ts @@ -0,0 +1,128 @@ +import { certificatesQueryKeys } from './queryKeys'; + +describe('certificatesQueryKeys', () => { + it('should generate all key', () => { + expect(certificatesQueryKeys.all).toEqual(['certificates']); + }); + + it('should generate lists key', () => { + expect(certificatesQueryKeys.lists()).toEqual(['certificates', 'list']); + }); + + it('should generate list key with all parameters', () => { + const result = certificatesQueryKeys.list( + 'course-123', + { page: 0, pageSize: 10 }, + 'john', + 'received' + ); + + expect(result).toEqual([ + 'certificates', + 'list', + 'course-123', + { page: 0, pageSize: 10 }, + 'john', + 'received', + ]); + }); + + it('should generate list key without search and filter', () => { + const result = certificatesQueryKeys.list( + 'course-456', + { page: 1, pageSize: 20 } + ); + + expect(result).toEqual([ + 'certificates', + 'list', + 'course-456', + { page: 1, pageSize: 20 }, + undefined, + undefined, + ]); + }); + + it('should generate list key with search only', () => { + const result = certificatesQueryKeys.list( + 'course-789', + { page: 2, pageSize: 15 }, + 'jane' + ); + + expect(result).toEqual([ + 'certificates', + 'list', + 'course-789', + { page: 2, pageSize: 15 }, + 'jane', + undefined, + ]); + }); + + it('should generate list key with filter only', () => { + const result = certificatesQueryKeys.list( + 'course-999', + { page: 0, pageSize: 10 }, + undefined, + 'error' + ); + + expect(result).toEqual([ + 'certificates', + 'list', + 'course-999', + { page: 0, pageSize: 10 }, + undefined, + 'error', + ]); + }); + + it('should generate history key', () => { + const result = certificatesQueryKeys.history( + 'course-111', + { page: 0, pageSize: 20 } + ); + + expect(result).toEqual([ + 'certificates', + 'history', + 'course-111', + { page: 0, pageSize: 20 }, + ]); + }); + + it('should generate config key', () => { + const result = certificatesQueryKeys.config('course-222'); + + expect(result).toEqual(['certificates', 'config', 'course-222']); + }); + + it('should generate different keys for different courses', () => { + const key1 = certificatesQueryKeys.list('course-a', { page: 0, pageSize: 10 }); + const key2 = certificatesQueryKeys.list('course-b', { page: 0, pageSize: 10 }); + + expect(key1).not.toEqual(key2); + }); + + it('should generate different keys for different pagination', () => { + const key1 = certificatesQueryKeys.list('course-123', { page: 0, pageSize: 10 }); + const key2 = certificatesQueryKeys.list('course-123', { page: 1, pageSize: 10 }); + + expect(key1).not.toEqual(key2); + }); + + it('should generate different keys for different search terms', () => { + const key1 = certificatesQueryKeys.list('course-123', { page: 0, pageSize: 10 }, 'john'); + const key2 = certificatesQueryKeys.list('course-123', { page: 0, pageSize: 10 }, 'jane'); + + expect(key1).not.toEqual(key2); + }); + + it('should generate different keys for different filters', () => { + const key1 = certificatesQueryKeys.list('course-123', { page: 0, pageSize: 10 }, undefined, 'received'); + const key2 = certificatesQueryKeys.list('course-123', { page: 0, pageSize: 10 }, undefined, 'error'); + + expect(key1).not.toEqual(key2); + }); +}); diff --git a/src/certificates/data/queryKeys.ts b/src/certificates/data/queryKeys.ts new file mode 100644 index 00000000..49d8f0a2 --- /dev/null +++ b/src/certificates/data/queryKeys.ts @@ -0,0 +1,13 @@ +import { PaginationQueryKeys } from '@src/types'; +import { CertificateFilter } from '../types'; + +export const certificatesQueryKeys = { + all: ['certificates'] as const, + lists: () => [...certificatesQueryKeys.all, 'list'] as const, + list: (courseId: string, pagination: PaginationQueryKeys, search?: string, filter?: CertificateFilter) => + [...certificatesQueryKeys.lists(), courseId, pagination, search, filter] as const, + history: (courseId: string, pagination: PaginationQueryKeys) => + [...certificatesQueryKeys.all, 'history', courseId, pagination] as const, + config: (courseId: string) => + [...certificatesQueryKeys.all, 'config', courseId] as const, +}; diff --git a/src/certificates/messages.ts b/src/certificates/messages.ts new file mode 100644 index 00000000..95cb537d --- /dev/null +++ b/src/certificates/messages.ts @@ -0,0 +1,211 @@ +import { defineMessages } from '@openedx/frontend-base'; + +const messages = defineMessages({ + certificatesTitle: { + id: 'certificates.title', + defaultMessage: 'Certificates', + description: 'Title for certificates page', + }, + issuedCertificatesTab: { + id: 'certificates.issuedCertificates.tab', + defaultMessage: 'Issued Certificates', + description: 'Tab label for issued certificates', + }, + generationHistoryTab: { + id: 'certificates.generationHistory.tab', + defaultMessage: 'Certificate Generation History', + description: 'Tab label for certificate generation history', + }, + searchPlaceholder: { + id: 'certificates.search.placeholder', + defaultMessage: 'Search for a Learner', + description: 'Placeholder for search input', + }, + allLearners: { + id: 'certificates.filter.allLearners', + defaultMessage: 'All Learners', + description: 'Filter option for all learners', + }, + received: { + id: 'certificates.filter.received', + defaultMessage: 'Received', + description: 'Filter option for received certificates', + }, + notReceived: { + id: 'certificates.filter.notReceived', + defaultMessage: 'Not Received', + description: 'Filter option for not received certificates', + }, + auditPassing: { + id: 'certificates.filter.auditPassing', + defaultMessage: 'Audit - Passing', + description: 'Filter option for audit passing', + }, + auditNotPassing: { + id: 'certificates.filter.auditNotPassing', + defaultMessage: 'Audit - Not Passing', + description: 'Filter option for audit not passing', + }, + errorState: { + id: 'certificates.filter.errorState', + defaultMessage: 'Error State', + description: 'Filter option for error state', + }, + grantedExceptions: { + id: 'certificates.filter.grantedExceptions', + defaultMessage: 'Granted Exceptions', + description: 'Filter option for granted exceptions', + }, + invalidated: { + id: 'certificates.filter.invalidated', + defaultMessage: 'Invalidated', + description: 'Filter option for invalidated certificates', + }, + regenerateCertificates: { + id: 'certificates.regenerate.button', + defaultMessage: 'Regenerate Certificates', + description: 'Button text for regenerating certificates', + }, + regenerating: { + id: 'certificates.regenerate.inProgress', + defaultMessage: 'Regenerating...', + description: 'Button text while regenerating certificates', + }, + username: { + id: 'certificates.table.username', + defaultMessage: 'Username', + description: 'Table column header for username', + }, + email: { + id: 'certificates.table.email', + defaultMessage: 'Email', + description: 'Table column header for email', + }, + enrollmentTrack: { + id: 'certificates.table.enrollmentTrack', + defaultMessage: 'Enrollment Track', + description: 'Table column header for enrollment track', + }, + certificateStatus: { + id: 'certificates.table.certificateStatus', + defaultMessage: 'Certificate Status', + description: 'Table column header for certificate status', + }, + specialCase: { + id: 'certificates.table.specialCase', + defaultMessage: 'Special Case', + description: 'Table column header for special case', + }, + exceptionGranted: { + id: 'certificates.table.exceptionGranted', + defaultMessage: 'Exception Granted', + description: 'Table column header for exception granted', + }, + exceptionNotes: { + id: 'certificates.table.exceptionNotes', + defaultMessage: 'Exception Notes', + description: 'Table column header for exception notes', + }, + invalidatedBy: { + id: 'certificates.table.invalidatedBy', + defaultMessage: 'Invalidated By', + description: 'Table column header for invalidated by', + }, + invalidationDate: { + id: 'certificates.table.invalidationDate', + defaultMessage: 'Invalidation Date', + description: 'Table column header for invalidation date', + }, + taskName: { + id: 'certificates.history.taskName', + defaultMessage: 'Task Name', + description: 'Table column header for task name', + }, + date: { + id: 'certificates.history.date', + defaultMessage: 'Date', + description: 'Table column header for date', + }, + details: { + id: 'certificates.history.details', + defaultMessage: 'Details', + description: 'Table column header for details', + }, + regenerateSuccess: { + id: 'certificates.regenerate.success', + defaultMessage: 'Certificate regeneration has been started', + description: 'Success message for certificate regeneration', + }, + regenerateError: { + id: 'certificates.regenerate.error', + defaultMessage: 'Error regenerating certificates', + description: 'Error message for certificate regeneration', + }, + close: { + id: 'certificates.close', + defaultMessage: 'Close', + description: 'Close button text', + }, + regenerateAllLearnersMessage: { + id: 'certificates.regenerate.modal.allLearners', + defaultMessage: 'Regenerate certificates for all learners who have received certificates.', + description: 'Modal message for regenerating certificates for received filter', + }, + regenerateNotReceivedMessage: { + id: 'certificates.regenerate.modal.notReceived', + defaultMessage: 'Generate certificates for learners who have not yet received certificates.', + description: 'Modal message for regenerating certificates for not received filter', + }, + regenerateAuditPassingMessage: { + id: 'certificates.regenerate.modal.auditPassing', + defaultMessage: 'Generate certificates for audit learners who are passing.', + description: 'Modal message for regenerating certificates for audit passing filter', + }, + regenerateAuditNotPassingMessage: { + id: 'certificates.regenerate.modal.auditNotPassing', + defaultMessage: 'Generate certificates for audit learners who are not passing.', + description: 'Modal message for regenerating certificates for audit not passing filter', + }, + regenerateErrorMessage: { + id: 'certificates.regenerate.modal.error', + defaultMessage: 'Regenerate certificates for learners whose certificate generation resulted in an error.', + description: 'Modal message for regenerating certificates for error filter', + }, + generateExceptionsMessage: { + id: 'certificates.regenerate.modal.exceptions', + defaultMessage: 'Generate certificates for learners who have been granted exceptions.', + description: 'Modal message for generating certificates for granted exceptions filter', + }, + regenerateCertificatesTitle: { + id: 'certificates.regenerate.modal.title', + defaultMessage: 'Regenerate Certificates', + description: 'Modal title for regenerating certificates', + }, + generateCertificatesTitle: { + id: 'certificates.generate.modal.title', + defaultMessage: 'Generate Certificates', + description: 'Modal title for generating certificates', + }, + regenerateConfirmation: { + id: 'certificates.regenerate.modal.confirmation', + defaultMessage: '{action} certificates for {number} learners?', + description: 'Confirmation message for regenerating/generating certificates', + }, + cancel: { + id: 'certificates.cancel', + defaultMessage: 'Cancel', + description: 'Cancel button text', + }, + regenerate: { + id: 'certificates.regenerate', + defaultMessage: 'Regenerate', + description: 'Regenerate button text', + }, + generate: { + id: 'certificates.generate', + defaultMessage: 'Generate', + description: 'Generate button text', + }, +}); + +export default messages; diff --git a/src/certificates/types.ts b/src/certificates/types.ts new file mode 100644 index 00000000..7284231f --- /dev/null +++ b/src/certificates/types.ts @@ -0,0 +1,32 @@ +export interface IssuedCertificate { + username: string, + email: string, + enrollmentTrack: string, + certificateStatus: string, + specialCase: string | null, + exceptionGranted: string | null, + exceptionNotes: string | null, + invalidatedBy: string | null, + invalidationDate: string | null, +} + +export interface CertificateGenerationHistory { + taskName: string, + date: string, + details: string, +} + +export interface RegenerateCertificatesParams { + statuses?: string[], + studentSet?: 'all' | 'allowlisted', +} + +export type CertificateFilter = + | 'all' + | 'received' + | 'not_received' + | 'audit_passing' + | 'audit_not_passing' + | 'error' + | 'granted_exceptions' + | 'invalidated'; diff --git a/src/routes.tsx b/src/routes.tsx index 8f646ef9..c3c311d0 100644 --- a/src/routes.tsx +++ b/src/routes.tsx @@ -66,6 +66,26 @@ const routes = [ path: ':tabId', element: }, + // { + // path: 'student_admin', + // element: + // }, + { + path: 'data_downloads', + element: + }, + // { + // path: 'special_exams', + // element: + // }, + { + path: 'certificates', + element: + }, + { + path: 'open_responses', + element: + } ] } ];