diff --git a/src/app.scss b/src/app.scss index 4b059ab0..1a749fcb 100644 --- a/src/app.scss +++ b/src/app.scss @@ -1,4 +1,9 @@ @use "@openedx/frontend-base/shell/app.scss"; +@import "./certificates/certificates.scss"; + +html, body { + overflow-x: hidden; +} .toast-container { left: unset; diff --git a/src/certificates/CertificatesPage.tsx b/src/certificates/CertificatesPage.tsx index fea3c361..7607dcbf 100644 --- a/src/certificates/CertificatesPage.tsx +++ b/src/certificates/CertificatesPage.tsx @@ -1,8 +1,217 @@ +import { useState, useMemo, useCallback } from 'react'; +import { useParams } from 'react-router-dom'; +import { Card, Container, Tab, Tabs } from '@openedx/paragon'; +import { useIntl } from '@openedx/frontend-base'; +import CertificatesPageHeader from './components/CertificatesPageHeader'; +import IssuedCertificatesTab from './components/IssuedCertificatesTab'; +import GenerationHistoryTable from './components/GenerationHistoryTable'; +import GrantExceptionsModal from './components/GrantExceptionsModal'; +import InvalidateCertificateModal from './components/InvalidateCertificateModal'; +import RemoveInvalidationModal from './components/RemoveInvalidationModal'; +import DisableCertificatesModal from './components/DisableCertificatesModal'; +import { dummyCertificateData } from './data/dummyData'; +import { + useGrantBulkExceptions, + useInstructorTasks, + useInvalidateCertificate, + useRemoveException, + useRemoveInvalidation, + useToggleCertificateGeneration, +} from './data/apiHook'; +import { CertificateFilter } from './types'; +import { useModalState } from './hooks/useModalState'; +import { useMutationCallbacks } from './hooks/useMutationCallbacks'; +import { filterCertificates } from './utils/filterUtils'; +import { parseLearnersCount } from './utils/errorHandling'; +import { CERTIFICATES_PAGE_SIZE, TAB_KEYS } from './constants'; +import messages from './messages'; + const CertificatesPage = () => { + const intl = useIntl(); + const { courseId = '' } = useParams<{ courseId: string }>(); + const { createCallbacks } = useMutationCallbacks(); + + const [filter, setFilter] = useState(CertificateFilter.ALL_LEARNERS); + const [search, setSearch] = useState(''); + const [certificatesPage, setCertificatesPage] = useState(0); + const [tasksPage, setTasksPage] = useState(0); + const [activeTab, setActiveTab] = useState(TAB_KEYS.ISSUED); + const [selectedUsername, setSelectedUsername] = useState(''); + const [selectedEmail, setSelectedEmail] = useState(''); + const [isCertificateGenerationEnabled, setIsCertificateGenerationEnabled] = useState(true); + + const [modals, modalActions] = useModalState(); + + const { + data: tasksData, + isLoading: isLoadingTasks, + } = useInstructorTasks(courseId, { + page: tasksPage, + pageSize: CERTIFICATES_PAGE_SIZE, + }); + + const { mutate: grantExceptions, isPending: isGrantingExceptions } = useGrantBulkExceptions(courseId); + const { mutate: invalidateCert, isPending: isInvalidating } = useInvalidateCertificate(courseId); + const { mutate: removeExcept } = useRemoveException(courseId); + const { mutate: removeInval, isPending: isRemovingInvalidation } = useRemoveInvalidation(courseId); + const { mutate: toggleGeneration, isPending: isTogglingGeneration } = useToggleCertificateGeneration(courseId); + + const filteredData = useMemo( + () => filterCertificates(dummyCertificateData, filter, search), + [filter, search], + ); + + const handleGrantExceptions = useCallback((learners: string, notes: string) => { + const count = parseLearnersCount(learners); + grantExceptions( + { learners, notes }, + createCallbacks({ + onSuccess: () => modalActions.closeGrantExceptions(), + successMessage: intl.formatMessage(messages.exceptionsGrantedToast, { count }), + errorMessage: intl.formatMessage(messages.errorGrantException), + }), + ); + }, [grantExceptions, createCallbacks, modalActions, intl]); + + const handleInvalidateCertificate = useCallback((learners: string, notes: string) => { + const count = parseLearnersCount(learners); + invalidateCert( + { learners, notes }, + createCallbacks({ + onSuccess: () => modalActions.closeInvalidateCertificate(), + successMessage: intl.formatMessage(messages.certificatesInvalidatedToast, { count }), + errorMessage: intl.formatMessage(messages.errorInvalidateCertificate), + }), + ); + }, [invalidateCert, createCallbacks, modalActions, intl]); + + const handleRemoveException = useCallback((username: string, email: string) => { + removeExcept( + { username }, + createCallbacks({ + successMessage: intl.formatMessage(messages.exceptionRemovedToast, { email }), + errorMessage: intl.formatMessage(messages.errorRemoveException), + }), + ); + }, [removeExcept, createCallbacks, intl]); + + const handleRemoveInvalidationClick = useCallback((username: string, email: string) => { + setSelectedUsername(username); + setSelectedEmail(email); + modalActions.openRemoveInvalidation(); + }, [modalActions]); + + const handleRemoveInvalidationConfirm = useCallback(() => { + removeInval( + { username: selectedUsername }, + createCallbacks({ + onSuccess: () => { + modalActions.closeRemoveInvalidation(); + setSelectedUsername(''); + setSelectedEmail(''); + }, + successMessage: intl.formatMessage(messages.invalidationRemovedToast, { email: selectedEmail }), + errorMessage: intl.formatMessage(messages.errorRemoveInvalidation), + }), + ); + }, [removeInval, selectedUsername, selectedEmail, createCallbacks, modalActions, intl]); + + const handleToggleCertificateGeneration = useCallback(() => { + const newState = !isCertificateGenerationEnabled; + toggleGeneration(newState, createCallbacks({ + onSuccess: () => { + setIsCertificateGenerationEnabled(newState); + modalActions.closeDisableCertificates(); + }, + successMessage: newState + ? intl.formatMessage(messages.successEnableCertificates) + : intl.formatMessage(messages.successDisableCertificates), + errorMessage: intl.formatMessage(messages.errorToggleCertificateGeneration), + })); + }, [isCertificateGenerationEnabled, toggleGeneration, createCallbacks, modalActions, intl]); + + const handleRegenerateCertificates = useCallback(() => { + // TODO: Implement when API is ready + }, []); + return ( -
-

Certificates

-
+ + + + + setActiveTab(key || TAB_KEYS.ISSUED)} + className="mx-4" + variant="button-group" + > + + + + +
+ +
+
+
+
+ + + + { + modalActions.closeRemoveInvalidation(); + setSelectedUsername(''); + setSelectedEmail(''); + }} + onConfirm={handleRemoveInvalidationConfirm} + isSubmitting={isRemovingInvalidation} + /> + +
); }; diff --git a/src/certificates/__tests__/CertificatesPage.test.tsx b/src/certificates/__tests__/CertificatesPage.test.tsx new file mode 100644 index 00000000..93deb5bf --- /dev/null +++ b/src/certificates/__tests__/CertificatesPage.test.tsx @@ -0,0 +1,157 @@ +import { screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import CertificatesPage from '../CertificatesPage'; +import { renderWithAlertAndIntl } from '@src/testUtils'; +import { + useGrantBulkExceptions, + useInstructorTasks, + useInvalidateCertificate, + useRemoveException, + useRemoveInvalidation, + useToggleCertificateGeneration, +} from '../data/apiHook'; +import messages from '../messages'; + +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useParams: () => ({ courseId: 'course-v1:edX+Test+2024' }), +})); + +jest.mock('../data/apiHook'); +jest.mock('../data/dummyData', () => ({ + dummyCertificateData: [ + { + username: 'user1', + email: 'user1@example.com', + enrollmentTrack: 'verified', + certificateStatus: 'downloadable', + specialCase: '', + }, + ], +})); + +const mockUseInstructorTasks = useInstructorTasks as jest.MockedFunction; +const mockUseGrantBulkExceptions = useGrantBulkExceptions as jest.MockedFunction; +const mockUseInvalidateCertificate = useInvalidateCertificate as jest.MockedFunction; +const mockUseRemoveException = useRemoveException as jest.MockedFunction; +const mockUseRemoveInvalidation = useRemoveInvalidation as jest.MockedFunction; +const mockUseToggleCertificateGeneration = useToggleCertificateGeneration as jest.MockedFunction; + +describe('CertificatesPage', () => { + const mockGrantExceptions = jest.fn(); + const mockInvalidateCert = jest.fn(); + const mockRemoveException = jest.fn(); + const mockRemoveInvalidation = jest.fn(); + const mockToggleGeneration = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + + mockUseInstructorTasks.mockReturnValue({ + data: { + results: [], + count: 0, + numPages: 0, + next: null, + previous: null, + }, + isLoading: false, + } as unknown as ReturnType); + + mockUseGrantBulkExceptions.mockReturnValue({ + mutate: mockGrantExceptions, + isPending: false, + } as unknown as ReturnType); + + mockUseInvalidateCertificate.mockReturnValue({ + mutate: mockInvalidateCert, + isPending: false, + } as unknown as ReturnType); + + mockUseRemoveException.mockReturnValue({ + mutate: mockRemoveException, + } as unknown as ReturnType); + + mockUseRemoveInvalidation.mockReturnValue({ + mutate: mockRemoveInvalidation, + isPending: false, + } as unknown as ReturnType); + + mockUseToggleCertificateGeneration.mockReturnValue({ + mutate: mockToggleGeneration, + isPending: false, + } as unknown as ReturnType); + }); + + it('renders page with header and tabs', () => { + renderWithAlertAndIntl(); + + expect(screen.getByText(messages.issuedCertificatesTab.defaultMessage)).toBeInTheDocument(); + expect(screen.getByText(messages.generationHistoryTab.defaultMessage)).toBeInTheDocument(); + }); + + it('renders issued certificates tab by default', () => { + renderWithAlertAndIntl(); + + expect(screen.getByText(messages.issuedCertificatesTab.defaultMessage)).toBeInTheDocument(); + }); + + it('switches to generation history tab', async () => { + renderWithAlertAndIntl(); + const user = userEvent.setup(); + + const historyTab = screen.getByText(messages.generationHistoryTab.defaultMessage); + await user.click(historyTab); + + await waitFor(() => { + expect(screen.getByText(messages.generationHistoryTab.defaultMessage)).toBeInTheDocument(); + }); + }); + + it('renders page header with action buttons', () => { + renderWithAlertAndIntl(); + + expect(screen.getByText(messages.grantExceptionsButton.defaultMessage)).toBeInTheDocument(); + expect(screen.getByText(messages.invalidateCertificateButton.defaultMessage)).toBeInTheDocument(); + }); + + it('opens Grant Exceptions modal when button is clicked', async () => { + renderWithAlertAndIntl(); + const user = userEvent.setup(); + + const grantButton = screen.getByText(messages.grantExceptionsButton.defaultMessage); + await user.click(grantButton); + + await waitFor(() => { + expect(screen.getByText(messages.grantExceptionsModalTitle.defaultMessage)).toBeInTheDocument(); + }); + }); + + it('opens Invalidate Certificate modal when button is clicked', async () => { + renderWithAlertAndIntl(); + const user = userEvent.setup(); + + const invalidateButton = screen.getByText(messages.invalidateCertificateButton.defaultMessage); + await user.click(invalidateButton); + + await waitFor(() => { + expect(screen.getByText(messages.invalidateCertificateModalTitle.defaultMessage)).toBeInTheDocument(); + }); + }); + + it('displays certificate data in table', () => { + renderWithAlertAndIntl(); + + expect(screen.getByText('user1')).toBeInTheDocument(); + expect(screen.getByText('user1@example.com')).toBeInTheDocument(); + }); + + it('fetches instructor tasks on mount', () => { + renderWithAlertAndIntl(); + + expect(mockUseInstructorTasks).toHaveBeenCalledWith( + 'course-v1:edX+Test+2024', + { page: 0, pageSize: 25 } + ); + }); +}); diff --git a/src/certificates/__tests__/components/CertificateTable.test.tsx b/src/certificates/__tests__/components/CertificateTable.test.tsx new file mode 100644 index 00000000..d920546f --- /dev/null +++ b/src/certificates/__tests__/components/CertificateTable.test.tsx @@ -0,0 +1,245 @@ +import { screen } from '@testing-library/react'; +import CertificateTable from '../../components/CertificateTable'; +import { renderWithIntl } from '@src/testUtils'; +import { CertificateData, CertificateFilter, CertificateStatus, SpecialCase } from '../../types'; +import messages from '../../messages'; + +describe('CertificateTable', () => { + const mockOnRemoveException = jest.fn(); + const mockOnRemoveInvalidation = jest.fn(); + const mockOnPageChange = jest.fn(); + + const mockCertificateData: CertificateData[] = [ + { + username: 'user1', + email: 'user1@example.com', + enrollmentTrack: 'verified', + certificateStatus: CertificateStatus.RECEIVED, + specialCase: SpecialCase.NONE, + }, + { + username: 'user2', + email: 'user2@example.com', + enrollmentTrack: 'audit', + certificateStatus: CertificateStatus.NOT_RECEIVED, + specialCase: SpecialCase.NONE, + }, + ]; + + const defaultProps = { + data: mockCertificateData, + isLoading: false, + itemCount: 2, + pageCount: 1, + currentPage: 0, + filter: CertificateFilter.ALL_LEARNERS, + onPageChange: mockOnPageChange, + onRemoveException: mockOnRemoveException, + onRemoveInvalidation: mockOnRemoveInvalidation, + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders table with certificate data', () => { + renderWithIntl(); + + expect(screen.getByText('user1')).toBeInTheDocument(); + expect(screen.getByText('user1@example.com')).toBeInTheDocument(); + expect(screen.getByText('verified')).toBeInTheDocument(); + }); + + it('displays all base columns', () => { + renderWithIntl(); + + expect(screen.getByText(messages.columnUsername.defaultMessage)).toBeInTheDocument(); + expect(screen.getByText(messages.columnEmail.defaultMessage)).toBeInTheDocument(); + expect(screen.getByText(messages.columnEnrollmentTrack.defaultMessage)).toBeInTheDocument(); + expect(screen.getByText(messages.columnCertificateStatus.defaultMessage)).toBeInTheDocument(); + expect(screen.getByText(messages.columnSpecialCase.defaultMessage)).toBeInTheDocument(); + }); + + it('shows exception columns when filter is GRANTED_EXCEPTIONS', () => { + const dataWithException: CertificateData[] = [ + { + username: 'user1', + email: 'user1@example.com', + enrollmentTrack: 'verified', + certificateStatus: CertificateStatus.RECEIVED, + specialCase: SpecialCase.EXCEPTION, + exceptionGranted: '2024-01-01', + exceptionNotes: 'Special case', + }, + ]; + + renderWithIntl( + + ); + + expect(screen.getByText(messages.columnExceptionGranted.defaultMessage)).toBeInTheDocument(); + expect(screen.getByText(messages.columnExceptionNotes.defaultMessage)).toBeInTheDocument(); + }); + + it('shows invalidation columns when filter is INVALIDATED', () => { + const dataWithInvalidation: CertificateData[] = [ + { + username: 'user1', + email: 'user1@example.com', + enrollmentTrack: 'verified', + certificateStatus: CertificateStatus.RECEIVED, + specialCase: SpecialCase.INVALIDATION, + invalidatedBy: 'admin', + invalidationDate: '2024-01-15T10:30:00Z', + invalidationNote: 'Invalid cert', + }, + ]; + + renderWithIntl( + + ); + + expect(screen.getByText(messages.columnInvalidatedBy.defaultMessage)).toBeInTheDocument(); + expect(screen.getByText(messages.columnInvalidationDate.defaultMessage)).toBeInTheDocument(); + expect(screen.getByText(messages.columnInvalidationNote.defaultMessage)).toBeInTheDocument(); + }); + + it('shows actions column with remove exception action when filter is GRANTED_EXCEPTIONS', async () => { + const dataWithException: CertificateData[] = [ + { + username: 'user1', + email: 'user1@example.com', + enrollmentTrack: 'verified', + certificateStatus: CertificateStatus.RECEIVED, + specialCase: SpecialCase.EXCEPTION, + exceptionGranted: '2024-01-01', + }, + ]; + + renderWithIntl( + + ); + + expect(screen.getByText(messages.columnActions.defaultMessage)).toBeInTheDocument(); + }); + + it('shows actions column with remove invalidation action when filter is INVALIDATED', async () => { + const dataWithInvalidation: CertificateData[] = [ + { + username: 'user1', + email: 'user1@example.com', + enrollmentTrack: 'verified', + certificateStatus: CertificateStatus.RECEIVED, + specialCase: SpecialCase.INVALIDATION, + invalidatedBy: 'admin', + invalidationDate: '2024-01-15', + }, + ]; + + renderWithIntl( + + ); + + expect(screen.getByText(messages.columnActions.defaultMessage)).toBeInTheDocument(); + }); + + it('does not show actions column for other filters', () => { + renderWithIntl(); + + expect(screen.queryByText(messages.columnActions.defaultMessage)).not.toBeInTheDocument(); + }); + + it('displays empty message when no data', () => { + renderWithIntl(); + + expect(screen.getByText(messages.noDataMessage.defaultMessage)).toBeInTheDocument(); + }); + + it('shows loading state', () => { + renderWithIntl(); + + // DataTable should handle loading state + expect(screen.getByText(messages.columnUsername.defaultMessage)).toBeInTheDocument(); + }); + + it('renders multiple rows of data', () => { + const multipleData: CertificateData[] = [ + { + username: 'user1', + email: 'user1@example.com', + enrollmentTrack: 'verified', + certificateStatus: CertificateStatus.RECEIVED, + specialCase: SpecialCase.NONE, + }, + { + username: 'user2', + email: 'user2@example.com', + enrollmentTrack: 'audit', + certificateStatus: CertificateStatus.AUDIT_PASSING, + specialCase: SpecialCase.NONE, + }, + { + username: 'user3', + email: 'user3@example.com', + enrollmentTrack: 'verified', + certificateStatus: CertificateStatus.ERROR_STATE, + specialCase: SpecialCase.NONE, + }, + ]; + + renderWithIntl(); + + expect(screen.getByText('user1')).toBeInTheDocument(); + expect(screen.getByText('user2')).toBeInTheDocument(); + expect(screen.getByText('user3')).toBeInTheDocument(); + }); + + it('formats invalidation date correctly', () => { + const dataWithInvalidation: CertificateData[] = [ + { + username: 'user1', + email: 'user1@example.com', + enrollmentTrack: 'verified', + certificateStatus: CertificateStatus.RECEIVED, + specialCase: SpecialCase.INVALIDATION, + invalidatedBy: 'admin', + invalidationDate: '2024-01-15T14:30:00Z', + invalidationNote: 'Invalid', + }, + ]; + + renderWithIntl( + + ); + + // Check that date is rendered (format may vary based on locale) + expect(screen.getByText(/2024/)).toBeInTheDocument(); + }); + + it('shows both exception and invalidation columns when filter is ALL_LEARNERS', () => { + renderWithIntl(); + + expect(screen.getByText(messages.columnExceptionGranted.defaultMessage)).toBeInTheDocument(); + expect(screen.getByText(messages.columnInvalidatedBy.defaultMessage)).toBeInTheDocument(); + }); +}); diff --git a/src/certificates/__tests__/components/CertificatesToolbar.test.tsx b/src/certificates/__tests__/components/CertificatesToolbar.test.tsx new file mode 100644 index 00000000..43d7e8ad --- /dev/null +++ b/src/certificates/__tests__/components/CertificatesToolbar.test.tsx @@ -0,0 +1,129 @@ +import { screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import CertificatesToolbar from '../../components/CertificatesToolbar'; +import { renderWithIntl } from '@src/testUtils'; +import { CertificateFilter } from '../../types'; +import messages from '../../messages'; + +describe('CertificatesToolbar', () => { + const mockOnSearchChange = jest.fn(); + const mockOnFilterChange = jest.fn(); + const mockOnRegenerateCertificates = jest.fn(); + + const defaultProps = { + search: '', + onSearchChange: mockOnSearchChange, + filter: CertificateFilter.ALL_LEARNERS, + onFilterChange: mockOnFilterChange, + onRegenerateCertificates: mockOnRegenerateCertificates, + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders search field with placeholder', () => { + renderWithIntl(); + + expect(screen.getByPlaceholderText(messages.searchPlaceholder.defaultMessage)).toBeInTheDocument(); + }); + + it('renders filter dropdown', () => { + renderWithIntl(); + + expect(screen.getByText(messages.filterAllLearners.defaultMessage)).toBeInTheDocument(); + }); + + it('renders regenerate certificates button', () => { + renderWithIntl(); + + expect(screen.getByText(messages.regenerateCertificatesButton.defaultMessage)).toBeInTheDocument(); + }); + + it('calls onSearchChange when search input changes', async () => { + renderWithIntl(); + const user = userEvent.setup(); + + const searchInput = screen.getByPlaceholderText(messages.searchPlaceholder.defaultMessage); + await user.type(searchInput, 'test'); + + expect(mockOnSearchChange).toHaveBeenCalled(); + }); + + it('displays search value in input field', () => { + renderWithIntl(); + + const searchInput = screen.getByPlaceholderText(messages.searchPlaceholder.defaultMessage) as HTMLInputElement; + expect(searchInput.value).toBe('testuser'); + }); + + it('calls onFilterChange when filter is changed', async () => { + renderWithIntl(); + const user = userEvent.setup(); + + const filterButton = screen.getByRole('button', { name: /All Learners/i }); + await user.click(filterButton); + + const receivedOption = screen.getByText(messages.filterReceived.defaultMessage); + await user.click(receivedOption); + + expect(mockOnFilterChange).toHaveBeenCalledWith(CertificateFilter.RECEIVED); + }); + + it('calls onRegenerateCertificates when regenerate button is clicked', async () => { + renderWithIntl(); + const user = userEvent.setup(); + + const regenerateButton = screen.getByText(messages.regenerateCertificatesButton.defaultMessage); + await user.click(regenerateButton); + + expect(mockOnRegenerateCertificates).toHaveBeenCalledTimes(1); + }); + + it('displays selected filter value', () => { + renderWithIntl(); + + expect(screen.getByText(messages.filterReceived.defaultMessage)).toBeInTheDocument(); + }); + + it('handles empty search string', () => { + renderWithIntl(); + + const searchInput = screen.getByPlaceholderText(messages.searchPlaceholder.defaultMessage) as HTMLInputElement; + expect(searchInput.value).toBe(''); + }); + + it('renders regenerate button with outline variant', () => { + renderWithIntl(); + + const regenerateButton = screen.getByText(messages.regenerateCertificatesButton.defaultMessage); + expect(regenerateButton.closest('button')).toHaveClass('btn-outline-primary'); + }); + + it('allows clearing search input', async () => { + renderWithIntl(); + const user = userEvent.setup(); + + const searchInput = screen.getByPlaceholderText(messages.searchPlaceholder.defaultMessage); + await user.clear(searchInput); + + expect(mockOnSearchChange).toHaveBeenCalled(); + }); + + it('handles multiple filter changes', async () => { + renderWithIntl(); + const user = userEvent.setup(); + + const filterButton = screen.getByRole('button', { name: /All Learners/i }); + await user.click(filterButton); + + expect(screen.getAllByText(messages.filterReceived.defaultMessage).length).toBeGreaterThan(0); + }); + + it('has proper layout structure', () => { + const { container } = renderWithIntl(); + + const toolbar = container.querySelector('.d-flex'); + expect(toolbar).toBeInTheDocument(); + }); +}); diff --git a/src/certificates/__tests__/components/DisableCertificatesModal.test.tsx b/src/certificates/__tests__/components/DisableCertificatesModal.test.tsx new file mode 100644 index 00000000..896cfc74 --- /dev/null +++ b/src/certificates/__tests__/components/DisableCertificatesModal.test.tsx @@ -0,0 +1,119 @@ +import { screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { IntlProvider } from '@openedx/frontend-base'; +import DisableCertificatesModal from '../../components/DisableCertificatesModal'; +import { renderWithIntl } from '@src/testUtils'; +import messages from '../../messages'; + +describe('DisableCertificatesModal', () => { + const mockOnClose = jest.fn(); + const mockOnConfirm = jest.fn(); + + const defaultProps = { + isOpen: true, + isEnabled: true, + onClose: mockOnClose, + onConfirm: mockOnConfirm, + isSubmitting: false, + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders disable modal when certificates are enabled', () => { + renderWithIntl(); + + expect(screen.getByRole('dialog')).toBeInTheDocument(); + }); + + it('renders enable modal when certificates are disabled', () => { + renderWithIntl(); + + expect(screen.getByRole('dialog')).toBeInTheDocument(); + }); + + it('renders confirm and cancel buttons', () => { + renderWithIntl(); + + expect(screen.getByRole('button', { name: messages.confirm.defaultMessage })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: messages.cancel.defaultMessage })).toBeInTheDocument(); + }); + + it('calls onConfirm when confirm button is clicked', async () => { + renderWithIntl(); + const user = userEvent.setup(); + + const confirmButton = screen.getByRole('button', { name: messages.confirm.defaultMessage }); + await user.click(confirmButton); + + expect(mockOnConfirm).toHaveBeenCalledTimes(1); + }); + + it('calls onClose when cancel button is clicked', async () => { + renderWithIntl(); + const user = userEvent.setup(); + + const cancelButton = screen.getByRole('button', { name: messages.cancel.defaultMessage }); + await user.click(cancelButton); + + expect(mockOnClose).toHaveBeenCalledTimes(1); + }); + + it('disables buttons when isSubmitting is true', () => { + renderWithIntl(); + + const confirmButton = screen.getByRole('button', { name: messages.confirm.defaultMessage }); + const cancelButton = screen.getByRole('button', { name: messages.cancel.defaultMessage }); + + expect(confirmButton).toBeDisabled(); + expect(cancelButton).toBeDisabled(); + }); + + it('does not render when isOpen is false', () => { + renderWithIntl(); + + expect(screen.queryByText(messages.disableCertificatesModalTitle.defaultMessage)).not.toBeInTheDocument(); + }); + + it('switches title and message based on isEnabled prop', () => { + const { rerender } = renderWithIntl( + + ); + + expect(screen.getByRole('dialog')).toBeInTheDocument(); + + rerender( + + + + ); + + expect(screen.getByRole('dialog')).toBeInTheDocument(); + }); + + it('enables buttons when not submitting', () => { + renderWithIntl(); + + const confirmButton = screen.getByRole('button', { name: messages.confirm.defaultMessage }); + const cancelButton = screen.getByRole('button', { name: messages.cancel.defaultMessage }); + + expect(confirmButton).not.toBeDisabled(); + expect(cancelButton).not.toBeDisabled(); + }); + + it('renders with small size modal', () => { + renderWithIntl(); + + const modal = screen.getByRole('dialog'); + expect(modal).toBeInTheDocument(); + }); + + it('does not have close button in header', () => { + renderWithIntl(); + + // Modal should not have the default close button (X) in header + const closeButtons = screen.queryAllByLabelText('Close'); + expect(closeButtons.length).toBe(0); + }); +}); diff --git a/src/certificates/__tests__/components/GenerationHistoryTable.test.tsx b/src/certificates/__tests__/components/GenerationHistoryTable.test.tsx new file mode 100644 index 00000000..314288ff --- /dev/null +++ b/src/certificates/__tests__/components/GenerationHistoryTable.test.tsx @@ -0,0 +1,214 @@ +import { screen } from '@testing-library/react'; +import GenerationHistoryTable from '../../components/GenerationHistoryTable'; +import { renderWithIntl } from '@src/testUtils'; +import { InstructorTask } from '../../types'; +import messages from '../../messages'; + +describe('GenerationHistoryTable', () => { + const mockOnPageChange = jest.fn(); + + const mockTaskData: InstructorTask[] = [ + { + taskId: 'task1', + taskName: 'Generate Certificates', + taskState: 'SUCCESS', + created: '2024-01-15T14:30:00Z', + updated: '2024-01-15T14:35:00Z', + taskOutput: 'Successfully generated 50 certificates', + }, + { + taskId: 'task2', + taskName: 'Regenerate Certificates', + taskState: 'FAILURE', + created: '2024-01-10T10:00:00Z', + updated: '2024-01-10T10:05:00Z', + taskOutput: 'Error: Failed to process', + }, + ]; + + const defaultProps = { + data: mockTaskData, + isLoading: false, + itemCount: 2, + pageCount: 1, + currentPage: 0, + onPageChange: mockOnPageChange, + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders table with task data', () => { + renderWithIntl(); + + expect(screen.getByText('Generate Certificates')).toBeInTheDocument(); + expect(screen.getByText('Regenerate Certificates')).toBeInTheDocument(); + }); + + it('displays all column headers', () => { + renderWithIntl(); + + expect(screen.getByText(messages.columnTaskName.defaultMessage)).toBeInTheDocument(); + expect(screen.getByText(messages.columnDate.defaultMessage)).toBeInTheDocument(); + expect(screen.getByText(messages.columnDetails.defaultMessage)).toBeInTheDocument(); + }); + + it('displays task state in details column', () => { + renderWithIntl(); + + expect(screen.getByText(/SUCCESS/)).toBeInTheDocument(); + expect(screen.getByText(/FAILURE/)).toBeInTheDocument(); + }); + + it('displays task output when available', () => { + renderWithIntl(); + + expect(screen.getByText('Successfully generated 50 certificates')).toBeInTheDocument(); + expect(screen.getByText('Error: Failed to process')).toBeInTheDocument(); + }); + + it('formats date correctly', () => { + renderWithIntl(); + + // Check that dates are rendered (format may vary based on locale) + expect(screen.getAllByText(/2024/).length).toBeGreaterThan(0); + }); + + it('displays empty message when no data', () => { + renderWithIntl(); + + expect(screen.getByText(messages.noTasksMessage.defaultMessage)).toBeInTheDocument(); + }); + + it('shows loading state', () => { + renderWithIntl(); + + // DataTable should handle loading state + expect(screen.getByText(messages.columnTaskName.defaultMessage)).toBeInTheDocument(); + }); + + it('renders pagination footer when items exist', () => { + renderWithIntl(); + + // TableFooter should be rendered when itemCount > 0 + const tableContainer = screen.getByText('Generate Certificates').closest('table'); + expect(tableContainer).toBeInTheDocument(); + }); + + it('does not render pagination footer when no items', () => { + renderWithIntl(); + + // No pagination should be shown when itemCount is 0 + expect(screen.queryByText(/Showing/)).not.toBeInTheDocument(); + }); + + it('renders multiple task rows', () => { + const multipleTasksData: InstructorTask[] = [ + { + taskId: 'task1', + taskName: 'Task 1', + taskState: 'SUCCESS', + created: '2024-01-15T14:30:00Z', + updated: '2024-01-15T14:35:00Z', + taskOutput: 'Output 1', + }, + { + taskId: 'task2', + taskName: 'Task 2', + taskState: 'PENDING', + created: '2024-01-14T10:00:00Z', + updated: '2024-01-14T10:05:00Z', + taskOutput: 'Output 2', + }, + { + taskId: 'task3', + taskName: 'Task 3', + taskState: 'RUNNING', + created: '2024-01-13T08:00:00Z', + updated: '2024-01-13T08:05:00Z', + taskOutput: '', + }, + ]; + + renderWithIntl( + + ); + + expect(screen.getByText('Task 1')).toBeInTheDocument(); + expect(screen.getByText('Task 2')).toBeInTheDocument(); + expect(screen.getByText('Task 3')).toBeInTheDocument(); + }); + + it('handles task without output', () => { + const taskWithoutOutput: InstructorTask[] = [ + { + taskId: 'task1', + taskName: 'Running Task', + taskState: 'RUNNING', + created: '2024-01-15T14:30:00Z', + updated: '2024-01-15T14:35:00Z', + taskOutput: '', + }, + ]; + + renderWithIntl(); + + expect(screen.getByText('Running Task')).toBeInTheDocument(); + expect(screen.getByText(/RUNNING/)).toBeInTheDocument(); + }); + + it('handles task without created date', () => { + const taskWithoutDate: InstructorTask[] = [ + { + taskId: 'task1', + taskName: 'Old Task', + taskState: 'SUCCESS', + created: '', + updated: '2024-01-15T14:35:00Z', + taskOutput: 'Completed', + }, + ]; + + renderWithIntl(); + + expect(screen.getByText('Old Task')).toBeInTheDocument(); + }); + + it('displays different task states correctly', () => { + const tasksWithDifferentStates: InstructorTask[] = [ + { + taskId: 'task1', + taskName: 'Success Task', + taskState: 'SUCCESS', + created: '2024-01-15T14:30:00Z', + updated: '2024-01-15T14:35:00Z', + taskOutput: 'Done', + }, + { + taskId: 'task2', + taskName: 'Pending Task', + taskState: 'PENDING', + created: '2024-01-14T14:30:00Z', + updated: '2024-01-14T14:35:00Z', + taskOutput: 'Waiting', + }, + { + taskId: 'task3', + taskName: 'Failed Task', + taskState: 'FAILURE', + created: '2024-01-13T14:30:00Z', + updated: '2024-01-13T14:35:00Z', + taskOutput: 'Error occurred', + }, + ]; + + renderWithIntl( + + ); + + expect(screen.getByText(/SUCCESS/)).toBeInTheDocument(); + expect(screen.getByText(/PENDING/)).toBeInTheDocument(); + expect(screen.getByText(/FAILURE/)).toBeInTheDocument(); + }); +}); diff --git a/src/certificates/__tests__/components/GrantExceptionsModal.test.tsx b/src/certificates/__tests__/components/GrantExceptionsModal.test.tsx new file mode 100644 index 00000000..5086b67d --- /dev/null +++ b/src/certificates/__tests__/components/GrantExceptionsModal.test.tsx @@ -0,0 +1,109 @@ +import { screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import GrantExceptionsModal from '../../components/GrantExceptionsModal'; +import { renderWithIntl } from '@src/testUtils'; +import messages from '../../messages'; + +describe('GrantExceptionsModal', () => { + const mockOnClose = jest.fn(); + const mockOnSubmit = jest.fn(); + + const defaultProps = { + isOpen: true, + onClose: mockOnClose, + onSubmit: mockOnSubmit, + isSubmitting: false, + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders modal with correct title', () => { + renderWithIntl(); + + expect(screen.getAllByText(messages.grantExceptionsModalTitle.defaultMessage)[0]).toBeInTheDocument(); + }); + + it('renders modal with correct description', () => { + renderWithIntl(); + + expect(screen.getByText(messages.grantExceptionsModalDescription.defaultMessage)).toBeInTheDocument(); + }); + + it('renders learners input field', () => { + renderWithIntl(); + + expect(screen.getByText(messages.learnersLabel.defaultMessage)).toBeInTheDocument(); + expect(screen.getByPlaceholderText(messages.learnersPlaceholder.defaultMessage)).toBeInTheDocument(); + }); + + it('renders notes input field', () => { + renderWithIntl(); + + expect(screen.getByText(messages.notesLabel.defaultMessage)).toBeInTheDocument(); + expect(screen.getByPlaceholderText(messages.notesPlaceholder.defaultMessage)).toBeInTheDocument(); + }); + + it('renders submit and cancel buttons', () => { + renderWithIntl(); + + expect(screen.getByRole('button', { name: messages.submit.defaultMessage })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: messages.cancel.defaultMessage })).toBeInTheDocument(); + }); + + it('calls onSubmit with learners and notes when form is submitted', async () => { + renderWithIntl(); + const user = userEvent.setup(); + + const learnersInput = screen.getByPlaceholderText(messages.learnersPlaceholder.defaultMessage); + const notesInput = screen.getByPlaceholderText(messages.notesPlaceholder.defaultMessage); + + await user.type(learnersInput, 'user1@example.com'); + await user.type(notesInput, 'Granting exception for completion'); + + const submitButton = screen.getByRole('button', { name: messages.submit.defaultMessage }); + await user.click(submitButton); + + expect(mockOnSubmit).toHaveBeenCalledWith('user1@example.com', 'Granting exception for completion'); + }); + + it('calls onClose when cancel button is clicked', async () => { + renderWithIntl(); + const user = userEvent.setup(); + + const cancelButton = screen.getByRole('button', { name: messages.cancel.defaultMessage }); + await user.click(cancelButton); + + expect(mockOnClose).toHaveBeenCalledTimes(1); + }); + + it('disables buttons when isSubmitting is true', () => { + renderWithIntl(); + + const submitButton = screen.getByRole('button', { name: messages.submit.defaultMessage }); + const cancelButton = screen.getByRole('button', { name: messages.cancel.defaultMessage }); + + expect(submitButton).toBeDisabled(); + expect(cancelButton).toBeDisabled(); + }); + + it('does not render when isOpen is false', () => { + renderWithIntl(); + + expect(screen.queryByText(messages.grantExceptionsModalTitle.defaultMessage)).not.toBeInTheDocument(); + }); + + it('handles multiple learners input', async () => { + renderWithIntl(); + const user = userEvent.setup(); + + const learnersInput = screen.getByPlaceholderText(messages.learnersPlaceholder.defaultMessage); + await user.type(learnersInput, 'user1, user2, user3'); + + const submitButton = screen.getByRole('button', { name: messages.submit.defaultMessage }); + await user.click(submitButton); + + expect(mockOnSubmit).toHaveBeenCalledWith('user1, user2, user3', ''); + }); +}); diff --git a/src/certificates/__tests__/components/InvalidateCertificateModal.test.tsx b/src/certificates/__tests__/components/InvalidateCertificateModal.test.tsx new file mode 100644 index 00000000..f664057a --- /dev/null +++ b/src/certificates/__tests__/components/InvalidateCertificateModal.test.tsx @@ -0,0 +1,112 @@ +import { screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import InvalidateCertificateModal from '../../components/InvalidateCertificateModal'; +import { renderWithIntl } from '@src/testUtils'; +import messages from '../../messages'; + +describe('InvalidateCertificateModal', () => { + const mockOnClose = jest.fn(); + const mockOnSubmit = jest.fn(); + + const defaultProps = { + isOpen: true, + onClose: mockOnClose, + onSubmit: mockOnSubmit, + isSubmitting: false, + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders modal with correct title', () => { + renderWithIntl(); + + expect(screen.getAllByText(messages.invalidateCertificateModalTitle.defaultMessage)[0]).toBeInTheDocument(); + }); + + it('renders modal with correct description', () => { + renderWithIntl(); + + expect(screen.getByText(messages.invalidateCertificateModalDescription.defaultMessage)).toBeInTheDocument(); + }); + + it('renders learners input field', () => { + renderWithIntl(); + + expect(screen.getByText(messages.learnersLabel.defaultMessage)).toBeInTheDocument(); + expect(screen.getByPlaceholderText(messages.learnersPlaceholder.defaultMessage)).toBeInTheDocument(); + }); + + it('renders notes input field', () => { + renderWithIntl(); + + expect(screen.getByText(messages.notesLabel.defaultMessage)).toBeInTheDocument(); + expect(screen.getByPlaceholderText(messages.notesPlaceholder.defaultMessage)).toBeInTheDocument(); + }); + + it('calls onSubmit with learners and notes when form is submitted', async () => { + renderWithIntl(); + const user = userEvent.setup(); + + const learnersInput = screen.getByPlaceholderText(messages.learnersPlaceholder.defaultMessage); + const notesInput = screen.getByPlaceholderText(messages.notesPlaceholder.defaultMessage); + + await user.type(learnersInput, 'user1@example.com, user2@example.com'); + await user.type(notesInput, 'Certificate invalidated due to violation'); + + const submitButton = screen.getByRole('button', { name: messages.submit.defaultMessage }); + await user.click(submitButton); + + expect(mockOnSubmit).toHaveBeenCalledWith( + 'user1@example.com, user2@example.com', + 'Certificate invalidated due to violation' + ); + }); + + it('calls onClose when cancel button is clicked', async () => { + renderWithIntl(); + const user = userEvent.setup(); + + const cancelButton = screen.getByRole('button', { name: messages.cancel.defaultMessage }); + await user.click(cancelButton); + + expect(mockOnClose).toHaveBeenCalledTimes(1); + }); + + it('disables buttons when isSubmitting is true', () => { + renderWithIntl(); + + const submitButton = screen.getByRole('button', { name: messages.submit.defaultMessage }); + const cancelButton = screen.getByRole('button', { name: messages.cancel.defaultMessage }); + + expect(submitButton).toBeDisabled(); + expect(cancelButton).toBeDisabled(); + }); + + it('does not render when isOpen is false', () => { + renderWithIntl(); + + expect(screen.queryByText(messages.invalidateCertificateModalTitle.defaultMessage)).not.toBeInTheDocument(); + }); + + it('submit button is disabled when learners field is empty', () => { + renderWithIntl(); + + const submitButton = screen.getByRole('button', { name: messages.submit.defaultMessage }); + expect(submitButton).toBeDisabled(); + }); + + it('allows submission without notes', async () => { + renderWithIntl(); + const user = userEvent.setup(); + + const learnersInput = screen.getByPlaceholderText(messages.learnersPlaceholder.defaultMessage); + await user.type(learnersInput, 'user1@example.com'); + + const submitButton = screen.getByRole('button', { name: messages.submit.defaultMessage }); + await user.click(submitButton); + + expect(mockOnSubmit).toHaveBeenCalledWith('user1@example.com', ''); + }); +}); diff --git a/src/certificates/__tests__/components/IssuedCertificatesTab.test.tsx b/src/certificates/__tests__/components/IssuedCertificatesTab.test.tsx new file mode 100644 index 00000000..a401fe1b --- /dev/null +++ b/src/certificates/__tests__/components/IssuedCertificatesTab.test.tsx @@ -0,0 +1,157 @@ +import { screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import IssuedCertificatesTab from '../../components/IssuedCertificatesTab'; +import { renderWithIntl } from '@src/testUtils'; +import { CertificateData, CertificateFilter, CertificateStatus, SpecialCase } from '../../types'; + +describe('IssuedCertificatesTab', () => { + const mockOnSearchChange = jest.fn(); + const mockOnFilterChange = jest.fn(); + const mockOnPageChange = jest.fn(); + const mockOnRemoveException = jest.fn(); + const mockOnRemoveInvalidation = jest.fn(); + const mockOnRegenerateCertificates = jest.fn(); + + const mockCertificateData: CertificateData[] = [ + { + username: 'user1', + email: 'user1@example.com', + enrollmentTrack: 'verified', + certificateStatus: CertificateStatus.RECEIVED, + specialCase: SpecialCase.NONE, + }, + ]; + + const defaultProps = { + data: mockCertificateData, + isLoading: false, + itemCount: 1, + pageCount: 1, + search: '', + onSearchChange: mockOnSearchChange, + filter: CertificateFilter.ALL_LEARNERS, + onFilterChange: mockOnFilterChange, + currentPage: 0, + onPageChange: mockOnPageChange, + onRemoveException: mockOnRemoveException, + onRemoveInvalidation: mockOnRemoveInvalidation, + onRegenerateCertificates: mockOnRegenerateCertificates, + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders CertificatesToolbar component', () => { + renderWithIntl(); + + // Toolbar elements should be present + expect(screen.getByPlaceholderText(/Search/i)).toBeInTheDocument(); + }); + + it('renders CertificateTable component', () => { + renderWithIntl(); + + // Table should display the data + expect(screen.getByText('user1')).toBeInTheDocument(); + expect(screen.getByText('user1@example.com')).toBeInTheDocument(); + }); + + it('passes search prop to toolbar', () => { + renderWithIntl(); + + const searchInput = screen.getByPlaceholderText(/Search/i) as HTMLInputElement; + expect(searchInput.value).toBe('testuser'); + }); + + it('passes filter prop to toolbar', () => { + renderWithIntl(); + + // Filter dropdown should show the selected filter + expect(screen.getByText(/Received/i)).toBeInTheDocument(); + }); + + it('calls onSearchChange when search input changes', async () => { + renderWithIntl(); + const user = userEvent.setup(); + + const searchInput = screen.getByPlaceholderText(/Search/i); + await user.type(searchInput, 'test'); + + expect(mockOnSearchChange).toHaveBeenCalled(); + }); + + it('calls onFilterChange when filter is changed', async () => { + renderWithIntl(); + const user = userEvent.setup(); + + const filterButton = screen.getByRole('button', { name: /All Learners/i }); + await user.click(filterButton); + + const receivedOptions = screen.getAllByText(/Received/i); + await user.click(receivedOptions[1]); + + expect(mockOnFilterChange).toHaveBeenCalled(); + }); + + it('calls onRegenerateCertificates when button is clicked', async () => { + renderWithIntl(); + const user = userEvent.setup(); + + const regenerateButton = screen.getByText(/Regenerate Certificates/i); + await user.click(regenerateButton); + + expect(mockOnRegenerateCertificates).toHaveBeenCalledTimes(1); + }); + + it('passes data to table component', () => { + const multipleData: CertificateData[] = [ + { + username: 'user1', + email: 'user1@example.com', + enrollmentTrack: 'verified', + certificateStatus: CertificateStatus.RECEIVED, + specialCase: SpecialCase.NONE, + }, + { + username: 'user2', + email: 'user2@example.com', + enrollmentTrack: 'audit', + certificateStatus: CertificateStatus.NOT_RECEIVED, + specialCase: SpecialCase.NONE, + }, + ]; + + renderWithIntl(); + + expect(screen.getByText('user1')).toBeInTheDocument(); + expect(screen.getByText('user2')).toBeInTheDocument(); + }); + + it('passes isLoading prop to table', () => { + renderWithIntl(); + + // Table should be present even when loading + expect(screen.getByRole('table')).toBeInTheDocument(); + }); + + it('displays correct page count and item count', () => { + renderWithIntl(); + + // Table should be rendered with pagination + expect(screen.getByRole('table')).toBeInTheDocument(); + }); + + it('has proper layout structure', () => { + const { container } = renderWithIntl(); + + const tabContainer = container.querySelector('.certificates-tab-container'); + expect(tabContainer).toBeInTheDocument(); + }); + + it('renders empty data correctly', () => { + renderWithIntl(); + + expect(screen.getByRole('table')).toBeInTheDocument(); + }); +}); diff --git a/src/certificates/__tests__/components/LearnerActionModal.test.tsx b/src/certificates/__tests__/components/LearnerActionModal.test.tsx new file mode 100644 index 00000000..6473b94b --- /dev/null +++ b/src/certificates/__tests__/components/LearnerActionModal.test.tsx @@ -0,0 +1,180 @@ +import { screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import LearnerActionModal from '../../components/LearnerActionModal'; +import { renderWithIntl } from '@src/testUtils'; + +describe('LearnerActionModal', () => { + const mockOnClose = jest.fn(); + const mockOnSubmit = jest.fn(); + + const defaultProps = { + isOpen: true, + onClose: mockOnClose, + onSubmit: mockOnSubmit, + isSubmitting: false, + title: 'Test Action Modal', + description: 'This is a test description', + learnersLabel: 'Enter Learners', + learnersPlaceholder: 'username1, username2', + notesLabel: 'Add Notes', + notesPlaceholder: 'Enter notes here', + submitLabel: 'Submit', + cancelLabel: 'Cancel', + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders modal with title and description', () => { + renderWithIntl(); + + expect(screen.getAllByText('Test Action Modal')[0]).toBeInTheDocument(); + expect(screen.getByText('This is a test description')).toBeInTheDocument(); + }); + + it('renders learners and notes input fields', () => { + renderWithIntl(); + + expect(screen.getByText('Enter Learners')).toBeInTheDocument(); + expect(screen.getByPlaceholderText('username1, username2')).toBeInTheDocument(); + expect(screen.getByText('Add Notes')).toBeInTheDocument(); + expect(screen.getByPlaceholderText('Enter notes here')).toBeInTheDocument(); + }); + + it('renders submit and cancel buttons', () => { + renderWithIntl(); + + expect(screen.getByRole('button', { name: 'Submit' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Cancel' })).toBeInTheDocument(); + }); + + it('disables submit button when learners field is empty', () => { + renderWithIntl(); + + const submitButton = screen.getByRole('button', { name: 'Submit' }); + expect(submitButton).toBeDisabled(); + }); + + it('enables submit button when learners field has value', async () => { + renderWithIntl(); + const user = userEvent.setup(); + + const learnersInput = screen.getByPlaceholderText('username1, username2'); + await user.type(learnersInput, 'testuser'); + + const submitButton = screen.getByRole('button', { name: 'Submit' }); + expect(submitButton).not.toBeDisabled(); + }); + + it('calls onSubmit with learners and notes when submit is clicked', async () => { + renderWithIntl(); + const user = userEvent.setup(); + + const learnersInput = screen.getByPlaceholderText('username1, username2'); + const notesInput = screen.getByPlaceholderText('Enter notes here'); + + await user.type(learnersInput, 'user1, user2'); + await user.type(notesInput, 'Test notes'); + + const submitButton = screen.getByRole('button', { name: 'Submit' }); + await user.click(submitButton); + + expect(mockOnSubmit).toHaveBeenCalledWith('user1, user2', 'Test notes'); + }); + + it('clears form fields after successful submit', async () => { + renderWithIntl(); + const user = userEvent.setup(); + + const learnersInput = screen.getByPlaceholderText('username1, username2') as HTMLTextAreaElement; + const notesInput = screen.getByPlaceholderText('Enter notes here') as HTMLTextAreaElement; + + await user.type(learnersInput, 'user1'); + await user.type(notesInput, 'notes'); + + const submitButton = screen.getByRole('button', { name: 'Submit' }); + await user.click(submitButton); + + await waitFor(() => { + expect(learnersInput.value).toBe(''); + expect(notesInput.value).toBe(''); + }); + }); + + it('calls onClose and clears fields when cancel is clicked', async () => { + renderWithIntl(); + const user = userEvent.setup(); + + const learnersInput = screen.getByPlaceholderText('username1, username2') as HTMLTextAreaElement; + await user.type(learnersInput, 'user1'); + + const cancelButton = screen.getByRole('button', { name: 'Cancel' }); + await user.click(cancelButton); + + expect(mockOnClose).toHaveBeenCalledTimes(1); + }); + + it('disables all buttons when isSubmitting is true', () => { + renderWithIntl(); + + const submitButton = screen.getByRole('button', { name: 'Submit' }); + const cancelButton = screen.getByRole('button', { name: 'Cancel' }); + + expect(submitButton).toBeDisabled(); + expect(cancelButton).toBeDisabled(); + }); + + it('does not call onSubmit when learners field contains only whitespace', async () => { + renderWithIntl(); + const user = userEvent.setup(); + + const learnersInput = screen.getByPlaceholderText('username1, username2'); + await user.type(learnersInput, ' '); + + const submitButton = screen.getByRole('button', { name: 'Submit' }); + expect(submitButton).toBeDisabled(); + }); + + it('does not render modal when isOpen is false', () => { + renderWithIntl(); + + expect(screen.queryByText('Test Action Modal')).not.toBeInTheDocument(); + }); + + it('allows submitting with learners but empty notes', async () => { + renderWithIntl(); + const user = userEvent.setup(); + + const learnersInput = screen.getByPlaceholderText('username1, username2'); + await user.type(learnersInput, 'user1'); + + const submitButton = screen.getByRole('button', { name: 'Submit' }); + await user.click(submitButton); + + expect(mockOnSubmit).toHaveBeenCalledWith('user1', ''); + }); + + it('handles multiline learner input', async () => { + renderWithIntl(); + const user = userEvent.setup(); + + const learnersInput = screen.getByPlaceholderText('username1, username2'); + await user.type(learnersInput, 'user1{Enter}user2{Enter}user3'); + + const submitButton = screen.getByRole('button', { name: 'Submit' }); + await user.click(submitButton); + + expect(mockOnSubmit).toHaveBeenCalledWith('user1\nuser2\nuser3', ''); + }); + + it('renders textarea with correct number of rows', () => { + renderWithIntl(); + + const learnersInput = screen.getByPlaceholderText('username1, username2'); + const notesInput = screen.getByPlaceholderText('Enter notes here'); + + expect(learnersInput).toHaveAttribute('rows', '4'); + expect(notesInput).toHaveAttribute('rows', '3'); + }); +}); diff --git a/src/certificates/__tests__/components/RemoveInvalidationModal.test.tsx b/src/certificates/__tests__/components/RemoveInvalidationModal.test.tsx new file mode 100644 index 00000000..7f28df17 --- /dev/null +++ b/src/certificates/__tests__/components/RemoveInvalidationModal.test.tsx @@ -0,0 +1,115 @@ +import { screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { IntlProvider } from '@openedx/frontend-base'; +import RemoveInvalidationModal from '../../components/RemoveInvalidationModal'; +import { renderWithIntl } from '@src/testUtils'; +import messages from '../../messages'; + +describe('RemoveInvalidationModal', () => { + const mockOnClose = jest.fn(); + const mockOnConfirm = jest.fn(); + + const defaultProps = { + isOpen: true, + email: 'user@example.com', + onClose: mockOnClose, + onConfirm: mockOnConfirm, + isSubmitting: false, + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders modal with correct title', () => { + renderWithIntl(); + + expect(screen.getAllByText(messages.removeInvalidationModalTitle.defaultMessage)[0]).toBeInTheDocument(); + }); + + it('renders message with email address', () => { + renderWithIntl(); + + const messageText = screen.getAllByText((_content, element) => { + return element?.textContent?.includes('user@example.com') || false; + })[0]; + expect(messageText).toBeInTheDocument(); + }); + + it('renders confirm and cancel buttons', () => { + renderWithIntl(); + + expect(screen.getByRole('button', { name: messages.removeInvalidationAction.defaultMessage })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: messages.cancel.defaultMessage })).toBeInTheDocument(); + }); + + it('calls onConfirm when confirm button is clicked', async () => { + renderWithIntl(); + const user = userEvent.setup(); + + const confirmButton = screen.getByRole('button', { name: messages.removeInvalidationAction.defaultMessage }); + await user.click(confirmButton); + + expect(mockOnConfirm).toHaveBeenCalledTimes(1); + }); + + it('calls onClose when cancel button is clicked', async () => { + renderWithIntl(); + const user = userEvent.setup(); + + const cancelButton = screen.getByRole('button', { name: messages.cancel.defaultMessage }); + await user.click(cancelButton); + + expect(mockOnClose).toHaveBeenCalledTimes(1); + }); + + it('disables buttons when isSubmitting is true', () => { + renderWithIntl(); + + const confirmButton = screen.getByRole('button', { name: messages.removeInvalidationAction.defaultMessage }); + const cancelButton = screen.getByRole('button', { name: messages.cancel.defaultMessage }); + + expect(confirmButton).toBeDisabled(); + expect(cancelButton).toBeDisabled(); + }); + + it('does not render when isOpen is false', () => { + renderWithIntl(); + + expect(screen.queryByText(messages.removeInvalidationModalTitle.defaultMessage)).not.toBeInTheDocument(); + }); + + it('renders with different email addresses', () => { + const { rerender } = renderWithIntl( + + ); + + expect(screen.getByRole('dialog')).toBeInTheDocument(); + + rerender( + + + + ); + + expect(screen.getByRole('dialog')).toBeInTheDocument(); + }); + + it('does not have close button in header', () => { + renderWithIntl(); + + // Modal should not have the default close button (X) in header + const closeButtons = screen.queryAllByLabelText('Close'); + expect(closeButtons.length).toBe(0); + }); + + it('enables buttons when not submitting', () => { + renderWithIntl(); + + const confirmButton = screen.getByRole('button', { name: messages.removeInvalidationAction.defaultMessage }); + const cancelButton = screen.getByRole('button', { name: messages.cancel.defaultMessage }); + + expect(confirmButton).not.toBeDisabled(); + expect(cancelButton).not.toBeDisabled(); + }); +}); diff --git a/src/certificates/__tests__/data/apiHook.test.ts b/src/certificates/__tests__/data/apiHook.test.ts new file mode 100644 index 00000000..e542197a --- /dev/null +++ b/src/certificates/__tests__/data/apiHook.test.ts @@ -0,0 +1,386 @@ +import React from 'react'; +import { renderHook, waitFor } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { + useIssuedCertificates, + useInstructorTasks, + useGrantBulkExceptions, + useInvalidateCertificate, + useRemoveException, + useRemoveInvalidation, + useToggleCertificateGeneration, +} from '../../data/apiHook'; +import { + getIssuedCertificates, + getInstructorTasks, + grantBulkExceptions, + invalidateCertificate, + removeException, + removeInvalidation, + toggleCertificateGeneration, +} from '../../data/api'; +import { CertificateFilter, CertificateStatus, SpecialCase } from '../../types'; + +jest.mock('../../data/api'); + +const mockGetIssuedCertificates = getIssuedCertificates as jest.MockedFunction; +const mockGetInstructorTasks = getInstructorTasks as jest.MockedFunction; +const mockGrantBulkExceptions = grantBulkExceptions as jest.MockedFunction; +const mockInvalidateCertificate = invalidateCertificate as jest.MockedFunction; +const mockRemoveException = removeException as jest.MockedFunction; +const mockRemoveInvalidation = removeInvalidation as jest.MockedFunction; +const mockToggleCertificateGeneration = toggleCertificateGeneration as jest.MockedFunction; + +const createWrapper = () => { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false }, + }, + }); + + function Wrapper({ children }: { children: React.ReactNode }) { + return React.createElement(QueryClientProvider, { client: queryClient }, children); + } + + return Wrapper; +}; + +describe('certificates api hooks', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('useIssuedCertificates', () => { + it('fetches issued certificates successfully', async () => { + const mockData = { + count: 2, + results: [ + { + username: 'user1', + email: 'user1@example.com', + enrollmentTrack: 'verified', + certificateStatus: CertificateStatus.RECEIVED, + specialCase: SpecialCase.NONE, + }, + ], + numPages: 1, + next: null, + previous: null, + }; + + mockGetIssuedCertificates.mockResolvedValue(mockData); + + const { result } = renderHook( + () => useIssuedCertificates('course-v1:Test+Course+2024', { + page: 0, + pageSize: 25, + filter: CertificateFilter.ALL_LEARNERS, + search: '', + }), + { wrapper: createWrapper() } + ); + + expect(result.current.isLoading).toBe(true); + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true); + }); + + expect(mockGetIssuedCertificates).toHaveBeenCalledWith('course-v1:Test+Course+2024', { + page: 0, + pageSize: 25, + filter: CertificateFilter.ALL_LEARNERS, + search: '', + }); + expect(result.current.data).toEqual(mockData); + }); + + it('handles API error', async () => { + const mockError = new Error('API Error'); + mockGetIssuedCertificates.mockRejectedValue(mockError); + + const { result } = renderHook( + () => useIssuedCertificates('course-v1:Test+Course+2024', { + page: 0, + pageSize: 25, + filter: CertificateFilter.ALL_LEARNERS, + search: '', + }), + { wrapper: createWrapper() } + ); + + await waitFor(() => { + expect(result.current.isError).toBe(true); + }); + + expect(result.current.error).toBe(mockError); + }); + + it('does not fetch when courseId is empty', () => { + renderHook( + () => useIssuedCertificates('', { + page: 0, + pageSize: 25, + filter: CertificateFilter.ALL_LEARNERS, + search: '', + }), + { wrapper: createWrapper() } + ); + + expect(mockGetIssuedCertificates).not.toHaveBeenCalled(); + }); + }); + + describe('useInstructorTasks', () => { + it('fetches instructor tasks successfully', async () => { + const mockData = { + count: 1, + results: [ + { + taskId: 'task1', + taskName: 'Generate Certificates', + taskState: 'SUCCESS', + created: '2024-01-15T10:00:00Z', + updated: '2024-01-15T10:05:00Z', + taskOutput: 'Success', + }, + ], + numPages: 1, + next: null, + previous: null, + }; + + mockGetInstructorTasks.mockResolvedValue(mockData); + + const { result } = renderHook( + () => useInstructorTasks('course-v1:Test+Course+2024', { page: 0, pageSize: 25 }), + { wrapper: createWrapper() } + ); + + expect(result.current.isLoading).toBe(true); + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true); + }); + + expect(mockGetInstructorTasks).toHaveBeenCalledWith('course-v1:Test+Course+2024', { + page: 0, + pageSize: 25, + }); + expect(result.current.data).toEqual(mockData); + }); + + it('handles API error', async () => { + const mockError = new Error('Task fetch error'); + mockGetInstructorTasks.mockRejectedValue(mockError); + + const { result } = renderHook( + () => useInstructorTasks('course-v1:Test+Course+2024', { page: 0, pageSize: 25 }), + { wrapper: createWrapper() } + ); + + await waitFor(() => { + expect(result.current.isError).toBe(true); + }); + + expect(result.current.error).toBe(mockError); + }); + }); + + describe('useGrantBulkExceptions', () => { + it('grants bulk exceptions successfully', async () => { + mockGrantBulkExceptions.mockResolvedValue(undefined); + + const { result } = renderHook(() => useGrantBulkExceptions('course-v1:Test+Course+2024'), { + wrapper: createWrapper(), + }); + + result.current.mutate({ learners: 'user1, user2', notes: 'Exception granted' }); + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true); + }); + + expect(mockGrantBulkExceptions).toHaveBeenCalledWith('course-v1:Test+Course+2024', { + learners: 'user1, user2', + notes: 'Exception granted', + }); + }); + + it('handles error when granting exceptions', async () => { + const mockError = new Error('Failed to grant exceptions'); + mockGrantBulkExceptions.mockRejectedValue(mockError); + + const { result } = renderHook(() => useGrantBulkExceptions('course-v1:Test+Course+2024'), { + wrapper: createWrapper(), + }); + + result.current.mutate({ learners: 'user1', notes: 'Test' }); + + await waitFor(() => { + expect(result.current.isError).toBe(true); + }); + + expect(result.current.error).toBe(mockError); + }); + }); + + describe('useInvalidateCertificate', () => { + it('invalidates certificate successfully', async () => { + mockInvalidateCertificate.mockResolvedValue(undefined); + + const { result } = renderHook(() => useInvalidateCertificate('course-v1:Test+Course+2024'), { + wrapper: createWrapper(), + }); + + result.current.mutate({ learners: 'user1', notes: 'Certificate invalid' }); + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true); + }); + + expect(mockInvalidateCertificate).toHaveBeenCalledWith('course-v1:Test+Course+2024', { + learners: 'user1', + notes: 'Certificate invalid', + }); + }); + + it('handles error when invalidating certificate', async () => { + const mockError = new Error('Failed to invalidate'); + mockInvalidateCertificate.mockRejectedValue(mockError); + + const { result } = renderHook(() => useInvalidateCertificate('course-v1:Test+Course+2024'), { + wrapper: createWrapper(), + }); + + result.current.mutate({ learners: 'user1', notes: '' }); + + await waitFor(() => { + expect(result.current.isError).toBe(true); + }); + }); + }); + + describe('useRemoveException', () => { + it('removes exception successfully', async () => { + mockRemoveException.mockResolvedValue(undefined); + + const { result } = renderHook(() => useRemoveException('course-v1:Test+Course+2024'), { + wrapper: createWrapper(), + }); + + result.current.mutate({ username: 'user1' }); + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true); + }); + + expect(mockRemoveException).toHaveBeenCalledWith('course-v1:Test+Course+2024', { + username: 'user1', + }); + }); + + it('handles error when removing exception', async () => { + const mockError = new Error('Failed to remove exception'); + mockRemoveException.mockRejectedValue(mockError); + + const { result } = renderHook(() => useRemoveException('course-v1:Test+Course+2024'), { + wrapper: createWrapper(), + }); + + result.current.mutate({ username: 'user1' }); + + await waitFor(() => { + expect(result.current.isError).toBe(true); + }); + }); + }); + + describe('useRemoveInvalidation', () => { + it('removes invalidation successfully', async () => { + mockRemoveInvalidation.mockResolvedValue(undefined); + + const { result } = renderHook(() => useRemoveInvalidation('course-v1:Test+Course+2024'), { + wrapper: createWrapper(), + }); + + result.current.mutate({ username: 'user1' }); + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true); + }); + + expect(mockRemoveInvalidation).toHaveBeenCalledWith('course-v1:Test+Course+2024', { + username: 'user1', + }); + }); + + it('handles error when removing invalidation', async () => { + const mockError = new Error('Failed to remove invalidation'); + mockRemoveInvalidation.mockRejectedValue(mockError); + + const { result } = renderHook(() => useRemoveInvalidation('course-v1:Test+Course+2024'), { + wrapper: createWrapper(), + }); + + result.current.mutate({ username: 'user1' }); + + await waitFor(() => { + expect(result.current.isError).toBe(true); + }); + }); + }); + + describe('useToggleCertificateGeneration', () => { + it('enables certificate generation successfully', async () => { + mockToggleCertificateGeneration.mockResolvedValue(undefined); + + const { result } = renderHook( + () => useToggleCertificateGeneration('course-v1:Test+Course+2024'), + { wrapper: createWrapper() } + ); + + result.current.mutate(true); + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true); + }); + + expect(mockToggleCertificateGeneration).toHaveBeenCalledWith('course-v1:Test+Course+2024', true); + }); + + it('disables certificate generation successfully', async () => { + mockToggleCertificateGeneration.mockResolvedValue(undefined); + + const { result } = renderHook( + () => useToggleCertificateGeneration('course-v1:Test+Course+2024'), + { wrapper: createWrapper() } + ); + + result.current.mutate(false); + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true); + }); + + expect(mockToggleCertificateGeneration).toHaveBeenCalledWith('course-v1:Test+Course+2024', false); + }); + + it('handles error when toggling certificate generation', async () => { + const mockError = new Error('Failed to toggle'); + mockToggleCertificateGeneration.mockRejectedValue(mockError); + + const { result } = renderHook( + () => useToggleCertificateGeneration('course-v1:Test+Course+2024'), + { wrapper: createWrapper() } + ); + + result.current.mutate(true); + + await waitFor(() => { + expect(result.current.isError).toBe(true); + }); + }); + }); +}); diff --git a/src/certificates/__tests__/hooks/useModalState.test.ts b/src/certificates/__tests__/hooks/useModalState.test.ts new file mode 100644 index 00000000..05491315 --- /dev/null +++ b/src/certificates/__tests__/hooks/useModalState.test.ts @@ -0,0 +1,129 @@ +import { renderHook, act } from '@testing-library/react'; +import { useModalState } from '../../hooks/useModalState'; + +describe('useModalState', () => { + it('initializes with all modals closed', () => { + const { result } = renderHook(() => useModalState()); + const [modalState] = result.current; + + expect(modalState.grantExceptions).toBe(false); + expect(modalState.invalidateCertificate).toBe(false); + expect(modalState.removeInvalidation).toBe(false); + expect(modalState.disableCertificates).toBe(false); + }); + + it('opens and closes grantExceptions modal', () => { + const { result } = renderHook(() => useModalState()); + + act(() => { + result.current[1].openGrantExceptions(); + }); + expect(result.current[0].grantExceptions).toBe(true); + + act(() => { + result.current[1].closeGrantExceptions(); + }); + expect(result.current[0].grantExceptions).toBe(false); + }); + + it('opens and closes invalidateCertificate modal', () => { + const { result } = renderHook(() => useModalState()); + + act(() => { + result.current[1].openInvalidateCertificate(); + }); + expect(result.current[0].invalidateCertificate).toBe(true); + + act(() => { + result.current[1].closeInvalidateCertificate(); + }); + expect(result.current[0].invalidateCertificate).toBe(false); + }); + + it('opens and closes removeInvalidation modal', () => { + const { result } = renderHook(() => useModalState()); + + act(() => { + result.current[1].openRemoveInvalidation(); + }); + expect(result.current[0].removeInvalidation).toBe(true); + + act(() => { + result.current[1].closeRemoveInvalidation(); + }); + expect(result.current[0].removeInvalidation).toBe(false); + }); + + it('opens and closes disableCertificates modal', () => { + const { result } = renderHook(() => useModalState()); + + act(() => { + result.current[1].openDisableCertificates(); + }); + expect(result.current[0].disableCertificates).toBe(true); + + act(() => { + result.current[1].closeDisableCertificates(); + }); + expect(result.current[0].disableCertificates).toBe(false); + }); + + it('allows multiple modals open simultaneously', () => { + const { result } = renderHook(() => useModalState()); + + act(() => { + result.current[1].openGrantExceptions(); + result.current[1].openInvalidateCertificate(); + }); + + expect(result.current[0].grantExceptions).toBe(true); + expect(result.current[0].invalidateCertificate).toBe(true); + expect(result.current[0].removeInvalidation).toBe(false); + expect(result.current[0].disableCertificates).toBe(false); + }); + + it('maintains independent state for each modal', () => { + const { result } = renderHook(() => useModalState()); + + act(() => { + result.current[1].openGrantExceptions(); + }); + expect(result.current[0].grantExceptions).toBe(true); + expect(result.current[0].invalidateCertificate).toBe(false); + + act(() => { + result.current[1].closeGrantExceptions(); + result.current[1].openRemoveInvalidation(); + }); + expect(result.current[0].grantExceptions).toBe(false); + expect(result.current[0].removeInvalidation).toBe(true); + }); + + it('can toggle modals multiple times', () => { + const { result } = renderHook(() => useModalState()); + + act(() => { + result.current[1].openGrantExceptions(); + }); + expect(result.current[0].grantExceptions).toBe(true); + + act(() => { + result.current[1].closeGrantExceptions(); + }); + expect(result.current[0].grantExceptions).toBe(false); + + act(() => { + result.current[1].openGrantExceptions(); + }); + expect(result.current[0].grantExceptions).toBe(true); + }); + + it('returns stable action references', () => { + const { result, rerender } = renderHook(() => useModalState()); + const initialActions = result.current[1]; + + rerender(); + + expect(result.current[1]).toBe(initialActions); + }); +}); diff --git a/src/certificates/__tests__/hooks/useMutationCallbacks.test.ts b/src/certificates/__tests__/hooks/useMutationCallbacks.test.ts new file mode 100644 index 00000000..ba86fac1 --- /dev/null +++ b/src/certificates/__tests__/hooks/useMutationCallbacks.test.ts @@ -0,0 +1,162 @@ +import { renderHook } from '@testing-library/react'; +import { useMutationCallbacks } from '../../hooks/useMutationCallbacks'; +import { useAlert } from '@src/providers/AlertProvider'; +import { ALERT_VARIANTS, MODAL_TITLES } from '../../constants'; +import type { ApiError } from '../../utils/errorHandling'; + +jest.mock('@src/providers/AlertProvider'); + +const mockUseAlert = useAlert as jest.MockedFunction; + +describe('useMutationCallbacks', () => { + const mockShowToast = jest.fn(); + const mockShowModal = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + mockUseAlert.mockReturnValue({ + showToast: mockShowToast, + showModal: mockShowModal, + } as unknown as ReturnType); + }); + + describe('createCallbacks', () => { + it('creates callbacks with success message', () => { + const { result } = renderHook(() => useMutationCallbacks()); + const callbacks = result.current.createCallbacks({ + successMessage: 'Operation successful!', + }); + + callbacks.onSuccess(); + + expect(mockShowToast).toHaveBeenCalledWith('Operation successful!'); + expect(mockShowToast).toHaveBeenCalledTimes(1); + }); + + it('does not show toast when no success message provided', () => { + const { result } = renderHook(() => useMutationCallbacks()); + const callbacks = result.current.createCallbacks({}); + + callbacks.onSuccess(); + + expect(mockShowToast).not.toHaveBeenCalled(); + }); + + it('calls custom onSuccess callback', () => { + const customOnSuccess = jest.fn(); + const { result } = renderHook(() => useMutationCallbacks()); + const callbacks = result.current.createCallbacks({ + onSuccess: customOnSuccess, + successMessage: 'Success!', + }); + + callbacks.onSuccess(); + + expect(customOnSuccess).toHaveBeenCalledTimes(1); + expect(mockShowToast).toHaveBeenCalledWith('Success!'); + }); + + it('shows modal on error with API error message', () => { + const { result } = renderHook(() => useMutationCallbacks()); + const callbacks = result.current.createCallbacks({ + errorMessage: 'Operation failed', + }); + + const apiError: ApiError = { + response: { + data: { + error: 'Specific API error', + }, + }, + }; + + callbacks.onError(apiError); + + expect(mockShowModal).toHaveBeenCalledWith({ + title: MODAL_TITLES.ERROR, + message: 'Specific API error', + variant: ALERT_VARIANTS.DANGER, + }); + }); + + it('uses fallback error message when API error has no details', () => { + const { result } = renderHook(() => useMutationCallbacks()); + const callbacks = result.current.createCallbacks({ + errorMessage: 'Something went wrong', + }); + + const apiError: ApiError = {}; + + callbacks.onError(apiError); + + expect(mockShowModal).toHaveBeenCalledWith({ + title: MODAL_TITLES.ERROR, + message: 'Something went wrong', + variant: ALERT_VARIANTS.DANGER, + }); + }); + + it('uses default error message when none provided', () => { + const { result } = renderHook(() => useMutationCallbacks()); + const callbacks = result.current.createCallbacks({}); + + const apiError: ApiError = {}; + + callbacks.onError(apiError); + + expect(mockShowModal).toHaveBeenCalledWith({ + title: MODAL_TITLES.ERROR, + message: 'An error occurred', + variant: ALERT_VARIANTS.DANGER, + }); + }); + + it('calls both success message and custom callback', () => { + const customOnSuccess = jest.fn(); + const { result } = renderHook(() => useMutationCallbacks()); + const callbacks = result.current.createCallbacks({ + onSuccess: customOnSuccess, + successMessage: 'Done!', + }); + + callbacks.onSuccess(); + + expect(mockShowToast).toHaveBeenCalledWith('Done!'); + expect(customOnSuccess).toHaveBeenCalled(); + }); + + it('handles error with generic message property', () => { + const { result } = renderHook(() => useMutationCallbacks()); + const callbacks = result.current.createCallbacks({ + errorMessage: 'Fallback error', + }); + + const apiError: ApiError = { + message: 'Generic error message', + }; + + callbacks.onError(apiError); + + expect(mockShowModal).toHaveBeenCalledWith({ + title: MODAL_TITLES.ERROR, + message: 'Generic error message', + variant: ALERT_VARIANTS.DANGER, + }); + }); + + it('creates independent callback sets', () => { + const { result } = renderHook(() => useMutationCallbacks()); + + const callbacks1 = result.current.createCallbacks({ successMessage: 'Success 1' }); + const callbacks2 = result.current.createCallbacks({ successMessage: 'Success 2' }); + + callbacks1.onSuccess(); + expect(mockShowToast).toHaveBeenCalledWith('Success 1'); + + mockShowToast.mockClear(); + + callbacks2.onSuccess(); + expect(mockShowToast).toHaveBeenCalledWith('Success 2'); + }); + }); +}); diff --git a/src/certificates/__tests__/utils/errorHandling.test.ts b/src/certificates/__tests__/utils/errorHandling.test.ts new file mode 100644 index 00000000..ef53cb59 --- /dev/null +++ b/src/certificates/__tests__/utils/errorHandling.test.ts @@ -0,0 +1,97 @@ +import { getErrorMessage, parseLearnersCount, type ApiError } from '../../utils/errorHandling'; + +describe('errorHandling', () => { + describe('getErrorMessage', () => { + it('returns error message from response data', () => { + const error: ApiError = { + response: { + data: { + error: 'API error occurred', + }, + }, + }; + expect(getErrorMessage(error, 'Fallback')).toBe('API error occurred'); + }); + + it('returns error message from error object', () => { + const error: ApiError = { + message: 'Direct error message', + }; + expect(getErrorMessage(error, 'Fallback')).toBe('Direct error message'); + }); + + it('returns fallback message when no error details', () => { + const error: ApiError = {}; + expect(getErrorMessage(error, 'Fallback message')).toBe('Fallback message'); + }); + + it('prioritizes response.data.error over message', () => { + const error: ApiError = { + response: { + data: { + error: 'Response error', + }, + }, + message: 'Generic message', + }; + expect(getErrorMessage(error, 'Fallback')).toBe('Response error'); + }); + + it('handles undefined error gracefully', () => { + const error: ApiError = {}; + expect(getErrorMessage(error, 'Default error')).toBe('Default error'); + }); + + it('handles empty error response', () => { + const error: ApiError = { + response: { + data: {}, + }, + }; + expect(getErrorMessage(error, 'Fallback')).toBe('Fallback'); + }); + }); + + describe('parseLearnersCount', () => { + it('counts single learner', () => { + expect(parseLearnersCount('user1')).toBe(1); + }); + + it('counts comma-separated learners', () => { + expect(parseLearnersCount('user1,user2,user3')).toBe(3); + }); + + it('counts newline-separated learners', () => { + expect(parseLearnersCount('user1\nuser2\nuser3')).toBe(3); + }); + + it('counts mixed comma and newline separators', () => { + expect(parseLearnersCount('user1,user2\nuser3,user4')).toBe(4); + }); + + it('returns 0 for empty string', () => { + expect(parseLearnersCount('')).toBe(0); + }); + + it('handles whitespace around usernames', () => { + expect(parseLearnersCount('user1 , user2 , user3')).toBe(3); + }); + + it('handles multiple newlines and commas', () => { + expect(parseLearnersCount('user1,,\n\nuser2,user3')).toBe(3); + }); + + it('filters out empty entries', () => { + expect(parseLearnersCount('user1,,,user2')).toBe(2); + expect(parseLearnersCount('user1\n\n\nuser2')).toBe(2); + }); + + it('handles single learner with whitespace', () => { + expect(parseLearnersCount(' user1 ')).toBe(1); + }); + + it('handles trailing commas and newlines', () => { + expect(parseLearnersCount('user1,user2,\nuser3\n')).toBe(3); + }); + }); +}); diff --git a/src/certificates/__tests__/utils/filterUtils.test.ts b/src/certificates/__tests__/utils/filterUtils.test.ts new file mode 100644 index 00000000..39ea2d81 --- /dev/null +++ b/src/certificates/__tests__/utils/filterUtils.test.ts @@ -0,0 +1,166 @@ +import { matchesFilter, matchesSearch, filterCertificates } from '../../utils/filterUtils'; +import { CertificateData, CertificateFilter, CertificateStatus, SpecialCase } from '../../types'; + +describe('filterUtils', () => { + const mockCertificate: CertificateData = { + username: 'testuser', + email: 'testuser@example.com', + enrollmentTrack: 'verified', + certificateStatus: CertificateStatus.RECEIVED, + specialCase: SpecialCase.NONE, + }; + + const mockExceptionCertificate: CertificateData = { + username: 'exceptionuser', + email: 'exception@example.com', + enrollmentTrack: 'verified', + certificateStatus: CertificateStatus.RECEIVED, + specialCase: SpecialCase.EXCEPTION, + }; + + const mockInvalidatedCertificate: CertificateData = { + username: 'invaliduser', + email: 'invalid@example.com', + enrollmentTrack: 'audit', + certificateStatus: CertificateStatus.NOT_RECEIVED, + specialCase: SpecialCase.INVALIDATION, + }; + + const mockNotReceivedCertificate: CertificateData = { + username: 'notpassing', + email: 'notpassing@example.com', + enrollmentTrack: 'verified', + certificateStatus: CertificateStatus.NOT_RECEIVED, + specialCase: SpecialCase.NONE, + }; + + describe('matchesFilter', () => { + it('returns true for ALL_LEARNERS filter', () => { + expect(matchesFilter(mockCertificate, CertificateFilter.ALL_LEARNERS)).toBe(true); + expect(matchesFilter(mockExceptionCertificate, CertificateFilter.ALL_LEARNERS)).toBe(true); + }); + + it('filters certificates with RECEIVED status', () => { + expect(matchesFilter(mockCertificate, CertificateFilter.RECEIVED)).toBe(true); + expect(matchesFilter(mockNotReceivedCertificate, CertificateFilter.RECEIVED)).toBe(false); + }); + + it('filters certificates with NOT_RECEIVED status', () => { + expect(matchesFilter(mockNotReceivedCertificate, CertificateFilter.NOT_RECEIVED)).toBe(true); + expect(matchesFilter(mockCertificate, CertificateFilter.NOT_RECEIVED)).toBe(false); + }); + + it('filters certificates with GRANTED_EXCEPTIONS special case', () => { + expect(matchesFilter(mockExceptionCertificate, CertificateFilter.GRANTED_EXCEPTIONS)).toBe(true); + expect(matchesFilter(mockCertificate, CertificateFilter.GRANTED_EXCEPTIONS)).toBe(false); + }); + + it('filters certificates with INVALIDATED special case', () => { + expect(matchesFilter(mockInvalidatedCertificate, CertificateFilter.INVALIDATED)).toBe(true); + expect(matchesFilter(mockCertificate, CertificateFilter.INVALIDATED)).toBe(false); + }); + + it('filters by AUDIT_PASSING status', () => { + const auditPassingCert: CertificateData = { + ...mockCertificate, + certificateStatus: CertificateStatus.AUDIT_PASSING, + }; + expect(matchesFilter(auditPassingCert, CertificateFilter.AUDIT_PASSING)).toBe(true); + expect(matchesFilter(mockCertificate, CertificateFilter.AUDIT_PASSING)).toBe(false); + }); + + it('filters by AUDIT_NOT_PASSING status', () => { + const auditNotPassingCert: CertificateData = { + ...mockCertificate, + certificateStatus: CertificateStatus.AUDIT_NOT_PASSING, + }; + expect(matchesFilter(auditNotPassingCert, CertificateFilter.AUDIT_NOT_PASSING)).toBe(true); + expect(matchesFilter(mockCertificate, CertificateFilter.AUDIT_NOT_PASSING)).toBe(false); + }); + + it('filters by ERROR_STATE status', () => { + const errorCert: CertificateData = { + ...mockCertificate, + certificateStatus: CertificateStatus.ERROR_STATE, + }; + expect(matchesFilter(errorCert, CertificateFilter.ERROR_STATE)).toBe(true); + expect(matchesFilter(mockCertificate, CertificateFilter.ERROR_STATE)).toBe(false); + }); + }); + + describe('matchesSearch', () => { + it('returns true when search is empty', () => { + expect(matchesSearch(mockCertificate, '')).toBe(true); + }); + + it('matches username case-insensitively', () => { + expect(matchesSearch(mockCertificate, 'TESTUSER')).toBe(true); + expect(matchesSearch(mockCertificate, 'test')).toBe(true); + expect(matchesSearch(mockCertificate, 'wronguser')).toBe(false); + }); + + it('matches email case-insensitively', () => { + expect(matchesSearch(mockCertificate, 'TESTUSER@EXAMPLE.COM')).toBe(true); + expect(matchesSearch(mockCertificate, 'example')).toBe(true); + expect(matchesSearch(mockCertificate, 'wrongemail')).toBe(false); + }); + + it('matches partial username', () => { + expect(matchesSearch(mockCertificate, 'test')).toBe(true); + expect(matchesSearch(mockCertificate, 'user')).toBe(true); + }); + + it('matches partial email', () => { + expect(matchesSearch(mockCertificate, '@example')).toBe(true); + expect(matchesSearch(mockCertificate, '.com')).toBe(true); + }); + }); + + describe('filterCertificates', () => { + const mockData: CertificateData[] = [ + mockCertificate, + mockExceptionCertificate, + mockInvalidatedCertificate, + mockNotReceivedCertificate, + ]; + + it('filters by both filter and search criteria', () => { + const result = filterCertificates(mockData, CertificateFilter.RECEIVED, 'testuser'); + expect(result).toHaveLength(1); + expect(result[0].username).toBe('testuser'); + }); + + it('returns all when filter is ALL_LEARNERS and search is empty', () => { + const result = filterCertificates(mockData, CertificateFilter.ALL_LEARNERS, ''); + expect(result).toHaveLength(4); + }); + + it('filters only by filter when search is empty', () => { + const result = filterCertificates(mockData, CertificateFilter.GRANTED_EXCEPTIONS, ''); + expect(result).toHaveLength(1); + expect(result[0].username).toBe('exceptionuser'); + }); + + it('filters only by search when filter is ALL_LEARNERS', () => { + const result = filterCertificates(mockData, CertificateFilter.ALL_LEARNERS, 'exception'); + expect(result).toHaveLength(1); + expect(result[0].username).toBe('exceptionuser'); + }); + + it('returns empty array when no matches', () => { + const result = filterCertificates(mockData, CertificateFilter.RECEIVED, 'nonexistent'); + expect(result).toHaveLength(0); + }); + + it('filters by NOT_RECEIVED status', () => { + const result = filterCertificates(mockData, CertificateFilter.NOT_RECEIVED, ''); + expect(result).toHaveLength(2); + }); + + it('combines multiple filter criteria correctly', () => { + const result = filterCertificates(mockData, CertificateFilter.INVALIDATED, 'invalid'); + expect(result).toHaveLength(1); + expect(result[0].specialCase).toBe(SpecialCase.INVALIDATION); + }); + }); +}); diff --git a/src/certificates/certificates.scss b/src/certificates/certificates.scss new file mode 100644 index 00000000..4766a3da --- /dev/null +++ b/src/certificates/certificates.scss @@ -0,0 +1,78 @@ +.filter-dropdown-item { + &:hover, + &.active { + text-decoration: none; + color: var(--pgn-color-menu-item-hover-color); + border-color: var(--pgn-color-menu-item-hover-border); + background: var(--pgn-color-menu-item-hover-bg); + } + + &.active { + font-weight: 600; + } +} + +.certificates-filter-dropdown, +.certificate-actions-dropdown { + position: relative; + z-index: 1050; + + .dropdown-menu { + position: absolute; + z-index: 9999; + max-height: 400px; + overflow-y: auto; + } +} + +.certificate-actions-dropdown { + position: static; + + .dropdown-toggle { + position: relative; + z-index: 1; + } + + .dropdown-menu { + will-change: transform; + } +} + +.certificates-card { + position: relative; + overflow: visible; + + .card-body, + .pgn__tabs, + .tab-content, + .tab-pane { + overflow: visible; + } +} + +.certificates-tab-container { + overflow: visible; +} + +.certificates-table-wrapper { + overflow-x: auto; + overflow-y: visible; + position: relative; +} + +.certificates-card .pgn__data-table-layout { + overflow: visible; + + table, + tbody, + tr, + td { + position: relative; + } +} + +.pgn__data-table-footer, +.pgn__data-table-pagination { + position: relative; + z-index: 1; +} diff --git a/src/certificates/components/CertificateTable.tsx b/src/certificates/components/CertificateTable.tsx new file mode 100644 index 00000000..84b1ed1b --- /dev/null +++ b/src/certificates/components/CertificateTable.tsx @@ -0,0 +1,177 @@ +import { useMemo } from 'react'; +import { DataTable, Dropdown, IconButton, Icon, TableFooter } from '@openedx/paragon'; +import { MoreVert } from '@openedx/paragon/icons'; +import { useIntl } from '@openedx/frontend-base'; +import type { CertificateData, CertificateFilter } from '../types'; +import { CertificateFilter as FilterEnum } from '../types'; +import { CERTIFICATES_TABLE_PAGE_SIZE } from '../constants'; +import messages from '../messages'; + +interface CertificateTableProps { + data: CertificateData[], + isLoading: boolean, + itemCount: number, + pageCount: number, + currentPage: number, + filter: CertificateFilter, + onPageChange: (pageIndex: number) => void, + onRemoveException: (username: string, email: string) => void, + onRemoveInvalidation: (username: string, email: string) => void, +} + +interface ColumnType { + Header: string, + accessor?: string, + id?: string, + Cell?: ({ row, value }: { row?: { original: CertificateData }, value?: string }) => JSX.Element | null, +} + +const CertificateTable = ({ + data, + isLoading, + itemCount, + filter, + onRemoveException, + onRemoveInvalidation, +}: CertificateTableProps) => { + const intl = useIntl(); + + const baseColumns = useMemo( + () => [ + { + Header: intl.formatMessage(messages.columnUsername), + accessor: 'username', + }, + { + Header: intl.formatMessage(messages.columnEmail), + accessor: 'email', + }, + { + Header: intl.formatMessage(messages.columnEnrollmentTrack), + accessor: 'enrollmentTrack', + }, + { + Header: intl.formatMessage(messages.columnCertificateStatus), + accessor: 'certificateStatus', + }, + { + Header: intl.formatMessage(messages.columnSpecialCase), + accessor: 'specialCase', + }, + ], + [intl], + ); + + const conditionalColumns = useMemo(() => { + const columns: ColumnType[] = []; + + if (filter === FilterEnum.ALL_LEARNERS || filter === FilterEnum.GRANTED_EXCEPTIONS) { + columns.push( + { + Header: intl.formatMessage(messages.columnExceptionGranted), + accessor: 'exceptionGranted', + }, + { + Header: intl.formatMessage(messages.columnExceptionNotes), + accessor: 'exceptionNotes', + }, + ); + } + + if (filter === FilterEnum.ALL_LEARNERS || filter === FilterEnum.INVALIDATED) { + columns.push( + { + Header: intl.formatMessage(messages.columnInvalidatedBy), + accessor: 'invalidatedBy', + }, + { + Header: intl.formatMessage(messages.columnInvalidationDate), + accessor: 'invalidationDate', + Cell: ({ value }: { value?: string }) => { + if (!value) return null; + return ( + <> + {intl.formatDate(new Date(value), { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + })} + + ); + }, + }, + { + Header: intl.formatMessage(messages.columnInvalidationNote), + accessor: 'invalidationNote', + }, + ); + } + + return columns; + }, [filter, intl]); + + const additionalColumns = useMemo(() => { + if (filter !== FilterEnum.GRANTED_EXCEPTIONS && filter !== FilterEnum.INVALIDATED) { + return []; + } + + return [ + { + id: 'actions', + Header: intl.formatMessage(messages.columnActions), + Cell: ({ row }: { row: { original: CertificateData } }) => ( + + + + {filter === FilterEnum.GRANTED_EXCEPTIONS && ( + onRemoveException(row.original.username, row.original.email)} + > + {intl.formatMessage(messages.removeExceptionAction)} + + )} + {filter === FilterEnum.INVALIDATED && ( + onRemoveInvalidation(row.original.username, row.original.email)} + > + {intl.formatMessage(messages.removeInvalidationAction)} + + )} + + + ), + }, + ]; + }, [filter, intl, onRemoveException, onRemoveInvalidation]); + + const allColumns = useMemo( + () => [...baseColumns, ...conditionalColumns, ...additionalColumns], + [baseColumns, conditionalColumns, additionalColumns], + ); + + return ( + + + + + + ); +}; + +export default CertificateTable; diff --git a/src/certificates/components/CertificatesPageHeader.tsx b/src/certificates/components/CertificatesPageHeader.tsx new file mode 100644 index 00000000..c4f6f56a --- /dev/null +++ b/src/certificates/components/CertificatesPageHeader.tsx @@ -0,0 +1,49 @@ +import { Button, IconButton, Stack } from '@openedx/paragon'; +import { Add, Close, MoreVert } from '@openedx/paragon/icons'; +import { useIntl } from '@openedx/frontend-base'; +import messages from '../messages'; + +interface CertificatesPageHeaderProps { + onGrantExceptions: () => void, + onInvalidateCertificate: () => void, + onDisableCertificates: () => void, +} + +const CertificatesPageHeader = ({ + onGrantExceptions, + onInvalidateCertificate, + onDisableCertificates, +}: CertificatesPageHeaderProps) => { + const intl = useIntl(); + + return ( +
+

{intl.formatMessage(messages.pageTitle)}

+ + + + + +
+ ); +}; + +export default CertificatesPageHeader; diff --git a/src/certificates/components/CertificatesToolbar.tsx b/src/certificates/components/CertificatesToolbar.tsx new file mode 100644 index 00000000..ce26a58a --- /dev/null +++ b/src/certificates/components/CertificatesToolbar.tsx @@ -0,0 +1,54 @@ +import { Button, SearchField } from '@openedx/paragon'; +import { SpinnerIcon } from '@openedx/paragon/icons'; +import { useIntl } from '@openedx/frontend-base'; +import FilterDropdown from './FilterDropdown'; +import { CertificateFilter } from '../types'; +import messages from '../messages'; + +interface CertificatesToolbarProps { + search: string, + onSearchChange: (value: string) => void, + filter: CertificateFilter, + onFilterChange: (value: CertificateFilter) => void, + onRegenerateCertificates: () => void, +} + +const CertificatesToolbar = ({ + search, + onSearchChange, + filter, + onFilterChange, + onRegenerateCertificates, +}: CertificatesToolbarProps) => { + const intl = useIntl(); + + return ( +
+
+ + +
+ +
+ ); +}; + +export default CertificatesToolbar; diff --git a/src/certificates/components/DisableCertificatesModal.tsx b/src/certificates/components/DisableCertificatesModal.tsx new file mode 100644 index 00000000..496ff429 --- /dev/null +++ b/src/certificates/components/DisableCertificatesModal.tsx @@ -0,0 +1,56 @@ +import { ActionRow, Button, ModalDialog } from '@openedx/paragon'; +import { useIntl } from '@openedx/frontend-base'; +import messages from '../messages'; + +interface DisableCertificatesModalProps { + isOpen: boolean, + isEnabled: boolean, + onClose: () => void, + onConfirm: () => void, + isSubmitting: boolean, +} + +const DisableCertificatesModal = ({ + isOpen, + isEnabled, + onClose, + onConfirm, + isSubmitting, +}: DisableCertificatesModalProps) => { + const intl = useIntl(); + + const title = isEnabled + ? intl.formatMessage(messages.disableCertificatesModalTitle) + : intl.formatMessage(messages.enableCertificatesModalTitle); + + const message = isEnabled + ? intl.formatMessage(messages.disableCertificatesModalMessage) + : intl.formatMessage(messages.enableCertificatesModalMessage); + + return ( + +
+

{message}

+
+ + + + + + +
+ ); +}; + +export default DisableCertificatesModal; diff --git a/src/certificates/components/FilterDropdown.tsx b/src/certificates/components/FilterDropdown.tsx new file mode 100644 index 00000000..306a27b5 --- /dev/null +++ b/src/certificates/components/FilterDropdown.tsx @@ -0,0 +1,84 @@ +import { Dropdown } from '@openedx/paragon'; +import { FilterList } from '@openedx/paragon/icons'; +import { useIntl } from '@openedx/frontend-base'; +import { CertificateFilter } from '../types'; +import messages from '../messages'; + +interface FilterDropdownProps { + value: CertificateFilter, + onChange: (value: CertificateFilter) => void, + className?: string, +} + +const FILTER_OPTIONS = [ + { + value: CertificateFilter.ALL_LEARNERS, + messageKey: 'filterAllLearners', + }, + { + value: CertificateFilter.RECEIVED, + messageKey: 'filterReceived', + }, + { + value: CertificateFilter.NOT_RECEIVED, + messageKey: 'filterNotReceived', + }, + { + value: CertificateFilter.AUDIT_PASSING, + messageKey: 'filterAuditPassing', + }, + { + value: CertificateFilter.AUDIT_NOT_PASSING, + messageKey: 'filterAuditNotPassing', + }, + { + value: CertificateFilter.ERROR_STATE, + messageKey: 'filterErrorState', + }, + { + value: CertificateFilter.GRANTED_EXCEPTIONS, + messageKey: 'filterGrantedExceptions', + }, + { + value: CertificateFilter.INVALIDATED, + messageKey: 'filterInvalidated', + }, +] as const; + +const FilterDropdown = ({ value, onChange, className }: FilterDropdownProps) => { + const intl = useIntl(); + + const selectedOption = FILTER_OPTIONS.find((option) => option.value === value); + const selectedLabel = selectedOption + ? intl.formatMessage(messages[selectedOption.messageKey]) + : intl.formatMessage(messages.filterAllLearners); + + return ( + + + + + {selectedLabel} + + + + {FILTER_OPTIONS.map((option) => ( + onChange(option.value)} + className="filter-dropdown-item" + > + {intl.formatMessage(messages[option.messageKey])} + + ))} + + + ); +}; + +export default FilterDropdown; diff --git a/src/certificates/components/GenerationHistoryTable.tsx b/src/certificates/components/GenerationHistoryTable.tsx new file mode 100644 index 00000000..308170f7 --- /dev/null +++ b/src/certificates/components/GenerationHistoryTable.tsx @@ -0,0 +1,93 @@ +import { useMemo } from 'react'; +import { DataTable } from '@openedx/paragon'; +import { useIntl } from '@openedx/frontend-base'; +import type { InstructorTask } from '../types'; +import messages from '../messages'; + +interface GenerationHistoryTableProps { + data: InstructorTask[], + isLoading: boolean, + itemCount: number, + pageCount: number, + currentPage: number, + onPageChange: (pageIndex: number) => void, +} + +const GenerationHistoryTable = ({ + data, + isLoading, + itemCount, + pageCount, + currentPage, + onPageChange, +}: GenerationHistoryTableProps) => { + const intl = useIntl(); + + const columns = useMemo( + () => [ + { + Header: intl.formatMessage(messages.columnTaskName), + accessor: 'taskName', + }, + { + Header: intl.formatMessage(messages.columnDate), + accessor: 'created', + Cell: ({ value }: { value: string }) => { + if (!value) return null; + return intl.formatDate(new Date(value), { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + }); + }, + }, + { + Header: intl.formatMessage(messages.columnDetails), + accessor: 'taskOutput', + Cell: ({ row }: { row: { original: InstructorTask } }) => { + const { taskState, taskOutput } = row.original; + return ( +
+
+ Status: {taskState} +
+ {taskOutput && ( +
+ {taskOutput} +
+ )} +
+ ); + }, + }, + ], + [intl], + ); + + return ( + onPageChange(pageIndex)} + initialState={{ pageIndex: currentPage, pageSize: 25 }} + > + + + {itemCount > 0 && ( + + + + + )} + + ); +}; + +export default GenerationHistoryTable; diff --git a/src/certificates/components/GrantExceptionsModal.tsx b/src/certificates/components/GrantExceptionsModal.tsx new file mode 100644 index 00000000..82922e53 --- /dev/null +++ b/src/certificates/components/GrantExceptionsModal.tsx @@ -0,0 +1,38 @@ +import { useIntl } from '@openedx/frontend-base'; +import LearnerActionModal from './LearnerActionModal'; +import messages from '../messages'; + +interface GrantExceptionsModalProps { + isOpen: boolean, + onClose: () => void, + onSubmit: (learners: string, notes: string) => void, + isSubmitting: boolean, +} + +const GrantExceptionsModal = ({ + isOpen, + onClose, + onSubmit, + isSubmitting, +}: GrantExceptionsModalProps) => { + const intl = useIntl(); + + return ( + + ); +}; + +export default GrantExceptionsModal; diff --git a/src/certificates/components/InvalidateCertificateModal.tsx b/src/certificates/components/InvalidateCertificateModal.tsx new file mode 100644 index 00000000..c830ec4b --- /dev/null +++ b/src/certificates/components/InvalidateCertificateModal.tsx @@ -0,0 +1,38 @@ +import { useIntl } from '@openedx/frontend-base'; +import LearnerActionModal from './LearnerActionModal'; +import messages from '../messages'; + +interface InvalidateCertificateModalProps { + isOpen: boolean, + onClose: () => void, + onSubmit: (learners: string, notes: string) => void, + isSubmitting: boolean, +} + +const InvalidateCertificateModal = ({ + isOpen, + onClose, + onSubmit, + isSubmitting, +}: InvalidateCertificateModalProps) => { + const intl = useIntl(); + + return ( + + ); +}; + +export default InvalidateCertificateModal; diff --git a/src/certificates/components/IssuedCertificatesTab.tsx b/src/certificates/components/IssuedCertificatesTab.tsx new file mode 100644 index 00000000..cb6fba94 --- /dev/null +++ b/src/certificates/components/IssuedCertificatesTab.tsx @@ -0,0 +1,60 @@ +import CertificateTable from './CertificateTable'; +import CertificatesToolbar from './CertificatesToolbar'; +import { CertificateData, CertificateFilter } from '../types'; + +interface IssuedCertificatesTabProps { + data: CertificateData[], + isLoading: boolean, + itemCount: number, + pageCount: number, + search: string, + onSearchChange: (value: string) => void, + filter: CertificateFilter, + onFilterChange: (value: CertificateFilter) => void, + currentPage: number, + onPageChange: (page: number) => void, + onRemoveException: (username: string, email: string) => void, + onRemoveInvalidation: (username: string, email: string) => void, + onRegenerateCertificates: () => void, +} + +const IssuedCertificatesTab = ({ + data, + isLoading, + itemCount, + pageCount, + search, + onSearchChange, + filter, + onFilterChange, + currentPage, + onPageChange, + onRemoveException, + onRemoveInvalidation, + onRegenerateCertificates, +}: IssuedCertificatesTabProps) => ( +
+ +
+ +
+
+); + +export default IssuedCertificatesTab; diff --git a/src/certificates/components/LearnerActionModal.tsx b/src/certificates/components/LearnerActionModal.tsx new file mode 100644 index 00000000..e7157d57 --- /dev/null +++ b/src/certificates/components/LearnerActionModal.tsx @@ -0,0 +1,99 @@ +import { useState } from 'react'; +import { ActionRow, Button, Form, ModalDialog } from '@openedx/paragon'; + +interface LearnerActionModalProps { + isOpen: boolean, + onClose: () => void, + onSubmit: (learners: string, notes: string) => void, + isSubmitting: boolean, + title: string, + description: string, + notesLabel: string, + notesPlaceholder: string, + submitLabel: string, + cancelLabel: string, + learnersLabel: string, + learnersPlaceholder: string, +} + +const LearnerActionModal = ({ + isOpen, + onClose, + onSubmit, + isSubmitting, + title, + description, + notesLabel, + notesPlaceholder, + submitLabel, + cancelLabel, + learnersLabel, + learnersPlaceholder, +}: LearnerActionModalProps) => { + const [learners, setLearners] = useState(''); + const [notes, setNotes] = useState(''); + + const handleSubmit = () => { + if (learners.trim()) { + onSubmit(learners, notes); + setLearners(''); + setNotes(''); + } + }; + + const handleClose = () => { + setLearners(''); + setNotes(''); + onClose(); + }; + + return ( + + + {title} + + +

{description}

+ + {learnersLabel} + setLearners(e.target.value)} + /> + + + {notesLabel} + setNotes(e.target.value)} + /> + +
+ + + + + + +
+ ); +}; + +export default LearnerActionModal; diff --git a/src/certificates/components/RemoveInvalidationModal.tsx b/src/certificates/components/RemoveInvalidationModal.tsx new file mode 100644 index 00000000..0e60688f --- /dev/null +++ b/src/certificates/components/RemoveInvalidationModal.tsx @@ -0,0 +1,55 @@ +import { ActionRow, Button, ModalDialog } from '@openedx/paragon'; +import { useIntl } from '@openedx/frontend-base'; +import messages from '../messages'; + +interface RemoveInvalidationModalProps { + isOpen: boolean, + email: string, + onClose: () => void, + onConfirm: () => void, + isSubmitting: boolean, +} + +const RemoveInvalidationModal = ({ + isOpen, + email, + onClose, + onConfirm, + isSubmitting, +}: RemoveInvalidationModalProps) => { + const intl = useIntl(); + + return ( + + + + {intl.formatMessage(messages.removeInvalidationModalTitle)} + + + +

+ {intl.formatMessage(messages.removeInvalidationModalMessage, { email })} +

+
+ + + + + + +
+ ); +}; + +export default RemoveInvalidationModal; diff --git a/src/certificates/constants.ts b/src/certificates/constants.ts new file mode 100644 index 00000000..14e45c38 --- /dev/null +++ b/src/certificates/constants.ts @@ -0,0 +1,18 @@ +export const CERTIFICATES_PAGE_SIZE = 25; +export const CERTIFICATES_TABLE_PAGE_SIZE = 10; + +export const TAB_KEYS = { + ISSUED: 'issued', + HISTORY: 'history', +} as const; + +export const ALERT_VARIANTS = { + SUCCESS: 'success', + DANGER: 'danger', + WARNING: 'warning', + INFO: 'info', +} as const; + +export const MODAL_TITLES = { + ERROR: 'Error', +} as const; diff --git a/src/certificates/data/api.ts b/src/certificates/data/api.ts new file mode 100644 index 00000000..fece115f --- /dev/null +++ b/src/certificates/data/api.ts @@ -0,0 +1,112 @@ +import { camelCaseObject, getAuthenticatedHttpClient } from '@openedx/frontend-base'; +import { getApiBaseUrl } from '@src/data/api'; +import type { + CertificateQueryParams, + CertificateResponse, + GrantExceptionRequest, + InstructorTasksResponse, + InvalidateCertificateRequest, + PaginationParams, + RemoveExceptionRequest, + RemoveInvalidationRequest, +} from '../types'; + +export const getIssuedCertificates = async ( + courseId: string, + params: CertificateQueryParams, +): Promise => { + const { data } = await getAuthenticatedHttpClient().get( + `${getApiBaseUrl()}/courses/${courseId}/instructor/api/get_issued_certificates/`, + { + params: { + page: params.page + 1, + page_size: params.pageSize, + filter: params.filter, + search: params.search, + }, + }, + ); + return camelCaseObject(data); +}; + +export const getInstructorTasks = async ( + courseId: string, + params: PaginationParams, +): Promise => { + const { data } = await getAuthenticatedHttpClient().get( + `${getApiBaseUrl()}/api/instructor/v2/courses/${courseId}/instructor_tasks`, + { + params: { + page: params.page + 1, + page_size: params.pageSize, + }, + }, + ); + return camelCaseObject(data); +}; + +export const grantBulkExceptions = async ( + courseId: string, + request: GrantExceptionRequest, +): Promise => { + await getAuthenticatedHttpClient().post( + `${getApiBaseUrl()}/courses/${courseId}/instructor/api/generate_bulk_certificate_exceptions`, + { + learners: request.learners, + notes: request.notes, + }, + ); +}; + +export const invalidateCertificate = async ( + courseId: string, + request: InvalidateCertificateRequest, +): Promise => { + await getAuthenticatedHttpClient().post( + `${getApiBaseUrl()}/courses/${courseId}/instructor/api/certificate_invalidation_view/`, + { + learners: request.learners, + notes: request.notes, + }, + ); +}; + +export const removeException = async ( + courseId: string, + request: RemoveExceptionRequest, +): Promise => { + await getAuthenticatedHttpClient().delete( + `${getApiBaseUrl()}/courses/${courseId}/instructor/api/certificate_exception_view/`, + { + data: { + username: request.username, + }, + }, + ); +}; + +export const removeInvalidation = async ( + courseId: string, + request: RemoveInvalidationRequest, +): Promise => { + await getAuthenticatedHttpClient().delete( + `${getApiBaseUrl()}/courses/${courseId}/instructor/api/certificate_invalidation_view/`, + { + data: { + username: request.username, + }, + }, + ); +}; + +export const toggleCertificateGeneration = async ( + courseId: string, + enable: boolean, +): Promise => { + await getAuthenticatedHttpClient().post( + `${getApiBaseUrl()}/courses/${courseId}/instructor/api/enable_certificate_generation`, + { + enabled: enable, + }, + ); +}; diff --git a/src/certificates/data/apiHook.ts b/src/certificates/data/apiHook.ts new file mode 100644 index 00000000..f1517be8 --- /dev/null +++ b/src/certificates/data/apiHook.ts @@ -0,0 +1,119 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import type { + CertificateQueryParams, + GrantExceptionRequest, + InvalidateCertificateRequest, + PaginationParams, + RemoveExceptionRequest, + RemoveInvalidationRequest, +} from '../types'; +import { + getInstructorTasks, + getIssuedCertificates, + grantBulkExceptions, + invalidateCertificate, + removeException, + removeInvalidation, + toggleCertificateGeneration, +} from './api'; +import { certificatesQueryKeys } from './queryKeys'; + +/** + * Hook to fetch issued certificates + */ +export const useIssuedCertificates = (courseId: string, params: CertificateQueryParams) => + useQuery({ + queryKey: certificatesQueryKeys.issued(courseId, params), + queryFn: () => getIssuedCertificates(courseId, params), + enabled: !!courseId, + }); + +/** + * Hook to fetch instructor tasks + */ +export const useInstructorTasks = (courseId: string, params: PaginationParams) => + useQuery({ + queryKey: certificatesQueryKeys.tasks(courseId, params), + queryFn: () => getInstructorTasks(courseId, params), + enabled: !!courseId, + }); + +/** + * Hook to grant bulk certificate exceptions + */ +export const useGrantBulkExceptions = (courseId: string) => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (request: GrantExceptionRequest) => grantBulkExceptions(courseId, request), + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: certificatesQueryKeys.byCourse(courseId), + exact: false, + }); + }, + }); +}; + +/** + * Hook to invalidate certificate + */ +export const useInvalidateCertificate = (courseId: string) => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (request: InvalidateCertificateRequest) => invalidateCertificate(courseId, request), + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: certificatesQueryKeys.byCourse(courseId), + exact: false, + }); + }, + }); +}; + +/** + * Hook to remove certificate exception + */ +export const useRemoveException = (courseId: string) => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (request: RemoveExceptionRequest) => removeException(courseId, request), + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: certificatesQueryKeys.byCourse(courseId), + exact: false, + }); + }, + }); +}; + +/** + * Hook to remove certificate invalidation + */ +export const useRemoveInvalidation = (courseId: string) => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (request: RemoveInvalidationRequest) => removeInvalidation(courseId, request), + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: certificatesQueryKeys.byCourse(courseId), + exact: false, + }); + }, + }); +}; + +/** + * Hook to toggle certificate generation + */ +export const useToggleCertificateGeneration = (courseId: string) => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (enable: boolean) => toggleCertificateGeneration(courseId, enable), + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: certificatesQueryKeys.byCourse(courseId), + exact: false, + }); + }, + }); +}; diff --git a/src/certificates/data/dummyData.ts b/src/certificates/data/dummyData.ts new file mode 100644 index 00000000..fa1333de --- /dev/null +++ b/src/certificates/data/dummyData.ts @@ -0,0 +1,234 @@ +import { CertificateData, CertificateStatus, SpecialCase } from '../types'; + +export const dummyCertificateData: CertificateData[] = [ + { + username: 'alice_smith', + email: 'alice.smith@example.com', + enrollmentTrack: 'verified', + certificateStatus: CertificateStatus.RECEIVED, + specialCase: SpecialCase.NONE, + }, + { + username: 'bob_jones', + email: 'bob.jones@example.com', + enrollmentTrack: 'audit', + certificateStatus: CertificateStatus.AUDIT_PASSING, + specialCase: SpecialCase.NONE, + }, + { + username: 'carol_williams', + email: 'carol.w@example.com', + enrollmentTrack: 'verified', + certificateStatus: CertificateStatus.NOT_RECEIVED, + specialCase: SpecialCase.NONE, + }, + { + username: 'david_brown', + email: 'david.brown@example.com', + enrollmentTrack: 'verified', + certificateStatus: CertificateStatus.RECEIVED, + specialCase: SpecialCase.EXCEPTION, + exceptionGranted: 'instructor_admin', + exceptionNotes: 'Student had documented illness during exam period', + }, + { + username: 'emma_davis', + email: 'emma.davis@example.com', + enrollmentTrack: 'audit', + certificateStatus: CertificateStatus.AUDIT_NOT_PASSING, + specialCase: SpecialCase.NONE, + }, + { + username: 'frank_miller', + email: 'frank.m@example.com', + enrollmentTrack: 'verified', + certificateStatus: CertificateStatus.RECEIVED, + specialCase: SpecialCase.INVALIDATION, + invalidatedBy: 'dean_office', + invalidationDate: '2026-02-15T14:30:00Z', + invalidationNote: 'Academic integrity violation confirmed', + }, + { + username: 'grace_wilson', + email: 'grace.wilson@example.com', + enrollmentTrack: 'verified', + certificateStatus: CertificateStatus.RECEIVED, + specialCase: SpecialCase.NONE, + }, + { + username: 'henry_moore', + email: 'henry.moore@example.com', + enrollmentTrack: 'professional', + certificateStatus: CertificateStatus.RECEIVED, + specialCase: SpecialCase.NONE, + }, + { + username: 'iris_taylor', + email: 'iris.taylor@example.com', + enrollmentTrack: 'verified', + certificateStatus: CertificateStatus.ERROR_STATE, + specialCase: SpecialCase.NONE, + }, + { + username: 'jack_anderson', + email: 'jack.anderson@example.com', + enrollmentTrack: 'verified', + certificateStatus: CertificateStatus.RECEIVED, + specialCase: SpecialCase.EXCEPTION, + exceptionGranted: 'course_staff', + exceptionNotes: 'Technical issues prevented submission - evidence provided', + }, + { + username: 'karen_thomas', + email: 'karen.thomas@example.com', + enrollmentTrack: 'audit', + certificateStatus: CertificateStatus.AUDIT_PASSING, + specialCase: SpecialCase.NONE, + }, + { + username: 'leo_jackson', + email: 'leo.jackson@example.com', + enrollmentTrack: 'verified', + certificateStatus: CertificateStatus.NOT_RECEIVED, + specialCase: SpecialCase.NONE, + }, + { + username: 'maria_white', + email: 'maria.white@example.com', + enrollmentTrack: 'verified', + certificateStatus: CertificateStatus.RECEIVED, + specialCase: SpecialCase.INVALIDATION, + invalidatedBy: 'system_admin', + invalidationDate: '2026-01-20T09:15:00Z', + invalidationNote: 'Duplicate enrollment detected', + }, + { + username: 'nathan_harris', + email: 'nathan.harris@example.com', + enrollmentTrack: 'verified', + certificateStatus: CertificateStatus.RECEIVED, + specialCase: SpecialCase.NONE, + }, + { + username: 'olivia_martin', + email: 'olivia.martin@example.com', + enrollmentTrack: 'professional', + certificateStatus: CertificateStatus.RECEIVED, + specialCase: SpecialCase.NONE, + }, + { + username: 'peter_garcia', + email: 'peter.garcia@example.com', + enrollmentTrack: 'audit', + certificateStatus: CertificateStatus.AUDIT_NOT_PASSING, + specialCase: SpecialCase.NONE, + }, + { + username: 'quinn_rodriguez', + email: 'quinn.r@example.com', + enrollmentTrack: 'verified', + certificateStatus: CertificateStatus.RECEIVED, + specialCase: SpecialCase.EXCEPTION, + exceptionGranted: 'instructor_admin', + exceptionNotes: 'Military deployment - coursework completed remotely', + }, + { + username: 'rachel_martinez', + email: 'rachel.martinez@example.com', + enrollmentTrack: 'verified', + certificateStatus: CertificateStatus.NOT_RECEIVED, + specialCase: SpecialCase.NONE, + }, + { + username: 'samuel_lee', + email: 'samuel.lee@example.com', + enrollmentTrack: 'verified', + certificateStatus: CertificateStatus.RECEIVED, + specialCase: SpecialCase.NONE, + }, + { + username: 'tina_gonzalez', + email: 'tina.gonzalez@example.com', + enrollmentTrack: 'verified', + certificateStatus: CertificateStatus.ERROR_STATE, + specialCase: SpecialCase.NONE, + }, + { + username: 'uma_clark', + email: 'uma.clark@example.com', + enrollmentTrack: 'verified', + certificateStatus: CertificateStatus.RECEIVED, + specialCase: SpecialCase.INVALIDATION, + invalidatedBy: 'course_staff', + invalidationDate: '2026-02-10T16:45:00Z', + invalidationNote: 'Identity verification failed', + }, + { + username: 'victor_lopez', + email: 'victor.lopez@example.com', + enrollmentTrack: 'audit', + certificateStatus: CertificateStatus.AUDIT_PASSING, + specialCase: SpecialCase.NONE, + }, + { + username: 'wendy_hill', + email: 'wendy.hill@example.com', + enrollmentTrack: 'professional', + certificateStatus: CertificateStatus.RECEIVED, + specialCase: SpecialCase.NONE, + }, + { + username: 'xavier_scott', + email: 'xavier.scott@example.com', + enrollmentTrack: 'verified', + certificateStatus: CertificateStatus.NOT_RECEIVED, + specialCase: SpecialCase.NONE, + }, + { + username: 'yara_green', + email: 'yara.green@example.com', + enrollmentTrack: 'verified', + certificateStatus: CertificateStatus.RECEIVED, + specialCase: SpecialCase.EXCEPTION, + exceptionGranted: 'dean_office', + exceptionNotes: 'Accessibility accommodation approved', + }, + { + username: 'zack_adams', + email: 'zack.adams@example.com', + enrollmentTrack: 'verified', + certificateStatus: CertificateStatus.RECEIVED, + specialCase: SpecialCase.NONE, + }, + { + username: 'amy_baker', + email: 'amy.baker@example.com', + enrollmentTrack: 'audit', + certificateStatus: CertificateStatus.AUDIT_NOT_PASSING, + specialCase: SpecialCase.NONE, + }, + { + username: 'brian_nelson', + email: 'brian.nelson@example.com', + enrollmentTrack: 'verified', + certificateStatus: CertificateStatus.RECEIVED, + specialCase: SpecialCase.INVALIDATION, + invalidatedBy: 'instructor_admin', + invalidationDate: '2026-02-25T11:20:00Z', + invalidationNote: 'Plagiarism confirmed by review board', + }, + { + username: 'claire_carter', + email: 'claire.carter@example.com', + enrollmentTrack: 'professional', + certificateStatus: CertificateStatus.RECEIVED, + specialCase: SpecialCase.NONE, + }, + { + username: 'derek_mitchell', + email: 'derek.mitchell@example.com', + enrollmentTrack: 'verified', + certificateStatus: CertificateStatus.NOT_RECEIVED, + specialCase: SpecialCase.NONE, + }, +]; diff --git a/src/certificates/data/queryKeys.ts b/src/certificates/data/queryKeys.ts new file mode 100644 index 00000000..81cc53a7 --- /dev/null +++ b/src/certificates/data/queryKeys.ts @@ -0,0 +1,11 @@ +import { appId } from '@src/constants'; +import type { CertificateQueryParams, PaginationParams } from '../types'; + +export const certificatesQueryKeys = { + all: [appId, 'certificates'] as const, + byCourse: (courseId: string) => [...certificatesQueryKeys.all, courseId] as const, + issued: (courseId: string, params: CertificateQueryParams) => + [...certificatesQueryKeys.byCourse(courseId), 'issued', params] as const, + tasks: (courseId: string, params: PaginationParams) => + [...certificatesQueryKeys.byCourse(courseId), 'tasks', params] as const, +}; diff --git a/src/certificates/hooks/index.ts b/src/certificates/hooks/index.ts new file mode 100644 index 00000000..09780a65 --- /dev/null +++ b/src/certificates/hooks/index.ts @@ -0,0 +1,3 @@ +export { useModalState } from './useModalState'; +export { useMutationCallbacks } from './useMutationCallbacks'; +export type { ModalState, ModalActions } from './useModalState'; diff --git a/src/certificates/hooks/useModalState.ts b/src/certificates/hooks/useModalState.ts new file mode 100644 index 00000000..97fe4ed1 --- /dev/null +++ b/src/certificates/hooks/useModalState.ts @@ -0,0 +1,50 @@ +import { useState, useCallback, useMemo } from 'react'; + +export interface ModalState { + grantExceptions: boolean, + invalidateCertificate: boolean, + removeInvalidation: boolean, + disableCertificates: boolean, +} + +export interface ModalActions { + openGrantExceptions: () => void, + closeGrantExceptions: () => void, + openInvalidateCertificate: () => void, + closeInvalidateCertificate: () => void, + openRemoveInvalidation: () => void, + closeRemoveInvalidation: () => void, + openDisableCertificates: () => void, + closeDisableCertificates: () => void, +} + +const initialState: ModalState = { + grantExceptions: false, + invalidateCertificate: false, + removeInvalidation: false, + disableCertificates: false, +}; + +export const useModalState = (): [ModalState, ModalActions] => { + const [modals, setModals] = useState(initialState); + + const toggleModal = useCallback((modalName: keyof ModalState, isOpen: boolean) => { + setModals((prev) => ({ ...prev, [modalName]: isOpen })); + }, []); + + const actions = useMemo( + () => ({ + openGrantExceptions: () => toggleModal('grantExceptions', true), + closeGrantExceptions: () => toggleModal('grantExceptions', false), + openInvalidateCertificate: () => toggleModal('invalidateCertificate', true), + closeInvalidateCertificate: () => toggleModal('invalidateCertificate', false), + openRemoveInvalidation: () => toggleModal('removeInvalidation', true), + closeRemoveInvalidation: () => toggleModal('removeInvalidation', false), + openDisableCertificates: () => toggleModal('disableCertificates', true), + closeDisableCertificates: () => toggleModal('disableCertificates', false), + }), + [toggleModal], + ); + + return [modals, actions]; +}; diff --git a/src/certificates/hooks/useMutationCallbacks.ts b/src/certificates/hooks/useMutationCallbacks.ts new file mode 100644 index 00000000..2c152db6 --- /dev/null +++ b/src/certificates/hooks/useMutationCallbacks.ts @@ -0,0 +1,35 @@ +import { useAlert } from '@src/providers/AlertProvider'; +import { ApiError, getErrorMessage } from '../utils/errorHandling'; +import { ALERT_VARIANTS, MODAL_TITLES } from '../constants'; + +interface UseMutationCallbacksOptions { + onSuccess?: () => void, + successMessage?: string, + errorMessage?: string, +} + +export const useMutationCallbacks = () => { + const { showToast, showModal } = useAlert(); + + const createCallbacks = ({ + onSuccess, + successMessage, + errorMessage = 'An error occurred', + }: UseMutationCallbacksOptions) => ({ + onSuccess: () => { + if (successMessage) { + showToast(successMessage); + } + onSuccess?.(); + }, + onError: (error: ApiError) => { + showModal({ + title: MODAL_TITLES.ERROR, + message: getErrorMessage(error, errorMessage), + variant: ALERT_VARIANTS.DANGER, + }); + }, + }); + + return { createCallbacks }; +}; diff --git a/src/certificates/messages.ts b/src/certificates/messages.ts new file mode 100644 index 00000000..a9f51dea --- /dev/null +++ b/src/certificates/messages.ts @@ -0,0 +1,316 @@ +import { defineMessages } from '@openedx/frontend-base'; + +const messages = defineMessages({ + pageTitle: { + id: 'instruct.certificates.pageTitle', + defaultMessage: 'Certificates', + description: 'Title for certificates page', + }, + grantExceptionsButton: { + id: 'instruct.certificates.grantExceptionsButton', + defaultMessage: 'Grant Exception(s)', + description: 'Button to grant certificate exceptions', + }, + invalidateCertificateButton: { + id: 'instruct.certificates.invalidateCertificateButton', + defaultMessage: 'Invalidate Certificate', + description: 'Button to invalidate certificates', + }, + disableCertificatesButton: { + id: 'instruct.certificates.disableCertificatesButton', + defaultMessage: 'Disable Certificates', + description: 'Button to disable certificate generation', + }, + regenerateCertificatesButton: { + id: 'instruct.certificates.regenerateCertificatesButton', + defaultMessage: 'Regenerate Certificates', + description: 'Button to regenerate certificates', + }, + issuedCertificatesTab: { + id: 'instruct.certificates.issuedCertificatesTab', + defaultMessage: 'Issued Certificates', + description: 'Tab label for issued certificates', + }, + generationHistoryTab: { + id: 'instruct.certificates.generationHistoryTab', + defaultMessage: 'Certificate Generation History', + description: 'Tab label for certificate generation history', + }, + searchPlaceholder: { + id: 'instruct.certificates.searchPlaceholder', + defaultMessage: 'Search by username or email', + description: 'Placeholder text for search input', + }, + filterAllLearners: { + id: 'instruct.certificates.filterAllLearners', + defaultMessage: 'All Learners', + description: 'Filter option for all learners', + }, + filterReceived: { + id: 'instruct.certificates.filterReceived', + defaultMessage: 'Received', + description: 'Filter option for learners who received certificates', + }, + filterNotReceived: { + id: 'instruct.certificates.filterNotReceived', + defaultMessage: 'Not Received', + description: 'Filter option for learners who did not receive certificates', + }, + filterAuditPassing: { + id: 'instruct.certificates.filterAuditPassing', + defaultMessage: 'Audit - Passing', + description: 'Filter option for audit learners who are passing', + }, + filterAuditNotPassing: { + id: 'instruct.certificates.filterAuditNotPassing', + defaultMessage: 'Audit - Not Passing', + description: 'Filter option for audit learners who are not passing', + }, + filterErrorState: { + id: 'instruct.certificates.filterErrorState', + defaultMessage: 'Error State', + description: 'Filter option for error states', + }, + filterGrantedExceptions: { + id: 'instruct.certificates.filterGrantedExceptions', + defaultMessage: 'Granted Exceptions', + description: 'Filter option for granted exceptions', + }, + filterInvalidated: { + id: 'instruct.certificates.filterInvalidated', + defaultMessage: 'Invalidated', + description: 'Filter option for invalidated certificates', + }, + columnUsername: { + id: 'instruct.certificates.columnUsername', + defaultMessage: 'Username', + description: 'Table column header for username', + }, + columnEmail: { + id: 'instruct.certificates.columnEmail', + defaultMessage: 'Email', + description: 'Table column header for email', + }, + columnEnrollmentTrack: { + id: 'instruct.certificates.columnEnrollmentTrack', + defaultMessage: 'Enrollment Track', + description: 'Table column header for enrollment track', + }, + columnCertificateStatus: { + id: 'instruct.certificates.columnCertificateStatus', + defaultMessage: 'Certificate Status', + description: 'Table column header for certificate status', + }, + columnSpecialCase: { + id: 'instruct.certificates.columnSpecialCase', + defaultMessage: 'Special Case', + description: 'Table column header for special case', + }, + columnExceptionGranted: { + id: 'instruct.certificates.columnExceptionGranted', + defaultMessage: 'Exception Granted', + description: 'Table column header for exception granted', + }, + columnExceptionNotes: { + id: 'instruct.certificates.columnExceptionNotes', + defaultMessage: 'Exception Notes', + description: 'Table column header for exception notes', + }, + columnInvalidatedBy: { + id: 'instruct.certificates.columnInvalidatedBy', + defaultMessage: 'Invalidated By', + description: 'Table column header for invalidated by', + }, + columnInvalidationDate: { + id: 'instruct.certificates.columnInvalidationDate', + defaultMessage: 'Invalidation Date', + description: 'Table column header for invalidation date', + }, + columnInvalidationNote: { + id: 'instruct.certificates.columnInvalidationNote', + defaultMessage: 'Invalidation Note', + description: 'Table column header for invalidation note', + }, + columnActions: { + id: 'instruct.certificates.columnActions', + defaultMessage: 'Actions', + description: 'Table column header for actions', + }, + removeExceptionAction: { + id: 'instruct.certificates.removeExceptionAction', + defaultMessage: 'Remove Exception', + description: 'Action menu item to remove exception', + }, + removeInvalidationAction: { + id: 'instruct.certificates.removeInvalidationAction', + defaultMessage: 'Remove Invalidation', + description: 'Action menu item to remove invalidation', + }, + noDataMessage: { + id: 'instruct.certificates.noDataMessage', + defaultMessage: 'No certificates found', + description: 'Message when no certificates are found', + }, + exceptionRemovedToast: { + id: 'instruct.certificates.exceptionRemovedToast', + defaultMessage: 'Exception removed for {email}', + description: 'Toast message when exception is removed', + }, + invalidationRemovedToast: { + id: 'instruct.certificates.invalidationRemovedToast', + defaultMessage: 'The certificate for {email} has been re-validated and the system is re-running the grade for this learner.', + description: 'Toast message when invalidation is removed', + }, + exceptionsGrantedToast: { + id: 'instruct.certificates.exceptionsGrantedToast', + defaultMessage: 'Exceptions granted for {count} learner(s)', + description: 'Toast message when exceptions are granted', + }, + certificatesInvalidatedToast: { + id: 'instruct.certificates.certificatesInvalidatedToast', + defaultMessage: 'Certificates invalidated for {count} learner(s)', + description: 'Toast message when certificates are invalidated', + }, + grantExceptionsModalTitle: { + id: 'instruct.certificates.grantExceptionsModalTitle', + defaultMessage: 'Grant Certificate Exceptions', + description: 'Title for grant exceptions modal', + }, + grantExceptionsModalDescription: { + id: 'instruct.certificates.grantExceptionsModalDescription', + defaultMessage: 'Enter usernames or emails, or upload a CSV file to grant certificate exceptions.', + description: 'Description for grant exceptions modal', + }, + invalidateCertificateModalTitle: { + id: 'instruct.certificates.invalidateCertificateModalTitle', + defaultMessage: 'Invalidate Certificates', + description: 'Title for invalidate certificate modal', + }, + invalidateCertificateModalDescription: { + id: 'instruct.certificates.invalidateCertificateModalDescription', + defaultMessage: 'Enter usernames or emails, or upload a CSV file to invalidate certificates.', + description: 'Description for invalidate certificate modal', + }, + removeInvalidationModalTitle: { + id: 'instruct.certificates.removeInvalidationModalTitle', + defaultMessage: 'Remove Invalidation', + description: 'Title for remove invalidation modal', + }, + removeInvalidationModalMessage: { + id: 'instruct.certificates.removeInvalidationModalMessage', + defaultMessage: 'Are you sure you want to remove invalidation for {email}?', + description: 'Message for remove invalidation confirmation modal', + }, + disableCertificatesModalTitle: { + id: 'instruct.certificates.disableCertificatesModalTitle', + defaultMessage: 'Disable Certificate Generation', + description: 'Title for disable certificates modal', + }, + disableCertificatesModalMessage: { + id: 'instruct.certificates.disableCertificatesModalMessage', + defaultMessage: 'Students will not be able to receive certificates until certificate generation is re-enabled. Are you sure you want to disable certificate generation?', + description: 'Message for disable certificates modal', + }, + enableCertificatesModalTitle: { + id: 'instruct.certificates.enableCertificatesModalTitle', + defaultMessage: 'Enable Certificate Generation', + description: 'Title for enable certificates modal', + }, + enableCertificatesModalMessage: { + id: 'instruct.certificates.enableCertificatesModalMessage', + defaultMessage: 'Are you sure you want to enable certificate generation for this course?', + description: 'Message for enable certificates modal', + }, + notesLabel: { + id: 'instruct.certificates.notesLabel', + defaultMessage: 'Notes (Optional)', + description: 'Label for notes field', + }, + notesPlaceholder: { + id: 'instruct.certificates.notesPlaceholder', + defaultMessage: 'Enter notes...', + description: 'Placeholder for notes field', + }, + learnersLabel: { + id: 'instruct.certificates.learnersLabel', + defaultMessage: 'Username or Email', + description: 'Label for learners field', + }, + learnersPlaceholder: { + id: 'instruct.certificates.learnersPlaceholder', + defaultMessage: 'Enter usernames or emails (one per line)', + description: 'Placeholder for learners field', + }, + cancel: { + id: 'instruct.certificates.cancel', + defaultMessage: 'Cancel', + description: 'Cancel button text', + }, + confirm: { + id: 'instruct.certificates.confirm', + defaultMessage: 'Confirm', + description: 'Confirm button text', + }, + submit: { + id: 'instruct.certificates.submit', + defaultMessage: 'Submit', + description: 'Submit button text', + }, + columnTaskName: { + id: 'instruct.certificates.columnTaskName', + defaultMessage: 'Task Name', + description: 'Table column header for task name', + }, + columnDate: { + id: 'instruct.certificates.columnDate', + defaultMessage: 'Date', + description: 'Table column header for date', + }, + columnDetails: { + id: 'instruct.certificates.columnDetails', + defaultMessage: 'Details', + description: 'Table column header for details', + }, + noTasksMessage: { + id: 'instruct.certificates.noTasksMessage', + defaultMessage: 'No certificate generation tasks found', + description: 'Message when no tasks are found', + }, + errorGrantException: { + id: 'instruct.certificates.errorGrantException', + defaultMessage: 'Failed to grant exceptions', + description: 'Error message when granting exceptions fails', + }, + errorInvalidateCertificate: { + id: 'instruct.certificates.errorInvalidateCertificate', + defaultMessage: 'Failed to invalidate certificates', + description: 'Error message when invalidating certificates fails', + }, + errorRemoveException: { + id: 'instruct.certificates.errorRemoveException', + defaultMessage: 'Failed to remove exception', + description: 'Error message when removing exception fails', + }, + errorRemoveInvalidation: { + id: 'instruct.certificates.errorRemoveInvalidation', + defaultMessage: 'Failed to remove invalidation', + description: 'Error message when removing invalidation fails', + }, + errorToggleCertificateGeneration: { + id: 'instruct.certificates.errorToggleCertificateGeneration', + defaultMessage: 'Failed to toggle certificate generation', + description: 'Error message when toggling certificate generation fails', + }, + successEnableCertificates: { + id: 'instruct.certificates.successEnableCertificates', + defaultMessage: 'Certificate generation enabled', + description: 'Success message when certificate generation is enabled', + }, + successDisableCertificates: { + id: 'instruct.certificates.successDisableCertificates', + defaultMessage: 'Certificate generation disabled', + description: 'Success message when certificate generation is disabled', + }, +}); + +export default messages; diff --git a/src/certificates/types.ts b/src/certificates/types.ts new file mode 100644 index 00000000..0a4ebd87 --- /dev/null +++ b/src/certificates/types.ts @@ -0,0 +1,90 @@ +export enum CertificateFilter { + ALL_LEARNERS = 'all', + RECEIVED = 'received', + NOT_RECEIVED = 'not_received', + AUDIT_PASSING = 'audit_passing', + AUDIT_NOT_PASSING = 'audit_not_passing', + ERROR_STATE = 'error', + GRANTED_EXCEPTIONS = 'granted_exceptions', + INVALIDATED = 'invalidated', +} + +export enum CertificateStatus { + RECEIVED = 'downloadable', + NOT_RECEIVED = 'notpassing', + AUDIT_PASSING = 'audit_passing', + AUDIT_NOT_PASSING = 'audit_notpassing', + ERROR_STATE = 'error', +} + +export enum SpecialCase { + NONE = '', + INVALIDATION = 'invalidated', + EXCEPTION = 'exception', +} + +export interface CertificateData { + username: string, + email: string, + enrollmentTrack: string, + certificateStatus: CertificateStatus, + specialCase: SpecialCase, + exceptionGranted?: string, + exceptionNotes?: string, + invalidatedBy?: string, + invalidationDate?: string, + invalidationNote?: string, +} + +export interface CertificateResponse { + count: number, + results: CertificateData[], + numPages: number, + next: string | null, + previous: string | null, +} + +export interface InstructorTask { + taskId: string, + taskName: string, + taskState: string, + taskOutput?: string, + created: string, + updated: string, +} + +export interface InstructorTasksResponse { + count: number, + results: InstructorTask[], + numPages: number, + next: string | null, + previous: string | null, +} + +export interface PaginationParams { + page: number, + pageSize: number, +} + +export interface CertificateQueryParams extends PaginationParams { + filter: CertificateFilter, + search: string, +} + +export interface GrantExceptionRequest { + learners: string, + notes?: string, +} + +export interface InvalidateCertificateRequest { + learners: string, + notes?: string, +} + +export interface RemoveExceptionRequest { + username: string, +} + +export interface RemoveInvalidationRequest { + username: string, +} diff --git a/src/certificates/utils/errorHandling.ts b/src/certificates/utils/errorHandling.ts new file mode 100644 index 00000000..e828afc0 --- /dev/null +++ b/src/certificates/utils/errorHandling.ts @@ -0,0 +1,14 @@ +export interface ApiError { + response?: { + data?: { + error?: string, + }, + }, + message?: string, +} + +export const getErrorMessage = (error: ApiError, fallbackMessage: string): string => + error?.response?.data?.error || error?.message || fallbackMessage; + +export const parseLearnersCount = (learners: string): number => + learners.split(/[\n,]/).filter((l) => l.trim()).length; diff --git a/src/certificates/utils/filterUtils.ts b/src/certificates/utils/filterUtils.ts new file mode 100644 index 00000000..1eaa5105 --- /dev/null +++ b/src/certificates/utils/filterUtils.ts @@ -0,0 +1,39 @@ +import { CertificateData, CertificateFilter, CertificateStatus, SpecialCase } from '../types'; + +export const matchesFilter = (item: CertificateData, filter: CertificateFilter): boolean => { + switch (filter) { + case CertificateFilter.RECEIVED: + return item.certificateStatus === CertificateStatus.RECEIVED; + case CertificateFilter.NOT_RECEIVED: + return item.certificateStatus === CertificateStatus.NOT_RECEIVED; + case CertificateFilter.AUDIT_PASSING: + return item.certificateStatus === CertificateStatus.AUDIT_PASSING; + case CertificateFilter.AUDIT_NOT_PASSING: + return item.certificateStatus === CertificateStatus.AUDIT_NOT_PASSING; + case CertificateFilter.ERROR_STATE: + return item.certificateStatus === CertificateStatus.ERROR_STATE; + case CertificateFilter.GRANTED_EXCEPTIONS: + return item.specialCase === SpecialCase.EXCEPTION; + case CertificateFilter.INVALIDATED: + return item.specialCase === SpecialCase.INVALIDATION; + case CertificateFilter.ALL_LEARNERS: + default: + return true; + } +}; + +export const matchesSearch = (item: CertificateData, search: string): boolean => { + if (!search) return true; + const searchLower = search.toLowerCase(); + return ( + item.username.toLowerCase().includes(searchLower) + || item.email.toLowerCase().includes(searchLower) + ); +}; + +export const filterCertificates = ( + data: CertificateData[], + filter: CertificateFilter, + search: string, +): CertificateData[] => + data.filter((item) => matchesFilter(item, filter) && matchesSearch(item, search)); diff --git a/src/certificates/utils/index.ts b/src/certificates/utils/index.ts new file mode 100644 index 00000000..b65c28cc --- /dev/null +++ b/src/certificates/utils/index.ts @@ -0,0 +1,3 @@ +export { filterCertificates, matchesFilter, matchesSearch } from './filterUtils'; +export { getErrorMessage, parseLearnersCount } from './errorHandling'; +export type { ApiError } from './errorHandling';