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