diff --git a/src/certificates/CertificatesPage.test.tsx b/src/certificates/CertificatesPage.test.tsx index fcd0244d..c310854f 100644 --- a/src/certificates/CertificatesPage.test.tsx +++ b/src/certificates/CertificatesPage.test.tsx @@ -13,6 +13,7 @@ import { useRemoveException, useRemoveInvalidation, useToggleCertificateGeneration, + useUploadBulkExceptionsCsv, } from '@src/certificates/data/apiHook'; import messages from '@src/certificates/messages'; @@ -31,6 +32,7 @@ const mockUseCertificateGenerationHistory = useCertificateGenerationHistory as j const mockUseInstructorTasks = useInstructorTasks as jest.MockedFunction; const mockUseIssuedCertificates = useIssuedCertificates as jest.MockedFunction; const mockUseGrantBulkExceptions = useGrantBulkExceptions as jest.MockedFunction; +const mockUseUploadBulkExceptionsCsv = useUploadBulkExceptionsCsv as jest.MockedFunction; const mockUseInvalidateCertificate = useInvalidateCertificate as jest.MockedFunction; const mockUseRegenerateCertificates = useRegenerateCertificates as jest.MockedFunction; const mockUseRemoveException = useRemoveException as jest.MockedFunction; @@ -142,6 +144,11 @@ describe('CertificatesPage', () => { isPending: false, } as unknown as ReturnType); + mockUseUploadBulkExceptionsCsv.mockReturnValue({ + mutate: jest.fn(), + isPending: false, + } as unknown as ReturnType); + mockUseInvalidateCertificate.mockReturnValue({ mutate: mockInvalidateCert, isPending: false, @@ -219,7 +226,7 @@ describe('CertificatesPage', () => { await user.click(invalidateButton); await waitFor(() => { - expect(screen.getByText(messages.invalidateCertificateModalTitle.defaultMessage)).toBeInTheDocument(); + expect(screen.getByRole('dialog')).toBeInTheDocument(); }); }); @@ -281,15 +288,15 @@ describe('CertificatesPage', () => { }); // Fill in the form - const learnersInput = screen.getByPlaceholderText(messages.learnersPlaceholder.defaultMessage); - const notesInput = screen.getByPlaceholderText(messages.notesPlaceholder.defaultMessage); + const learnerInput = screen.getByPlaceholderText(messages.studentEmailUsername.defaultMessage); + const notesInput = screen.getByPlaceholderText(messages.notesOptional.defaultMessage); - await user.type(learnersInput, 'user1'); + await user.type(learnerInput, 'user1'); await user.type(notesInput, 'Test note'); // Submit the form - const submitButton = screen.getByText(messages.submit.defaultMessage); - await user.click(submitButton); + const saveButton = screen.getByText(messages.save.defaultMessage); + await user.click(saveButton); // Verify mutation was called expect(mockGrantExceptions).toHaveBeenCalledWith( @@ -320,11 +327,11 @@ describe('CertificatesPage', () => { }); // Fill in and submit the form - const learnersInput = screen.getByPlaceholderText(messages.learnersPlaceholder.defaultMessage); - await user.type(learnersInput, 'user1'); + const learnerInput = screen.getByPlaceholderText(messages.studentEmailUsername.defaultMessage); + await user.type(learnerInput, 'user1'); - const submitButton = screen.getByText(messages.submit.defaultMessage); - await user.click(submitButton); + const saveButton = screen.getByText(messages.save.defaultMessage); + await user.click(saveButton); // Verify the modal is closed (side effect of onSuccess) await waitFor(() => { @@ -352,11 +359,11 @@ describe('CertificatesPage', () => { }); // Fill in and submit the form - const learnersInput = screen.getByPlaceholderText(messages.learnersPlaceholder.defaultMessage); - await user.type(learnersInput, 'user1'); + const learnerInput = screen.getByPlaceholderText(messages.studentEmailUsername.defaultMessage); + await user.type(learnerInput, 'user1'); - const submitButton = screen.getByText(messages.submit.defaultMessage); - await user.click(submitButton); + const saveButton = screen.getByText(messages.save.defaultMessage); + await user.click(saveButton); // Verify mutation was called (error alert is shown by AlertProvider) expect(mockGrantExceptions).toHaveBeenCalled(); @@ -383,17 +390,147 @@ describe('CertificatesPage', () => { expect(screen.getByText(messages.grantExceptionsModalTitle.defaultMessage)).toBeInTheDocument(); }); - const learnersInput = screen.getByPlaceholderText(messages.learnersPlaceholder.defaultMessage); - await user.type(learnersInput, 'user1, user2'); + const learnerInput = screen.getByPlaceholderText(messages.studentEmailUsername.defaultMessage); + await user.type(learnerInput, 'user1'); - const submitButton = screen.getByText(messages.submit.defaultMessage); - await user.click(submitButton); + const saveButton = screen.getByText(messages.save.defaultMessage); + await user.click(saveButton); // Modal should close await waitFor(() => { expect(screen.queryByText(messages.grantExceptionsModalTitle.defaultMessage)).not.toBeInTheDocument(); }); }); + + it('handles CSV upload with success', async () => { + const mockUploadCsv = jest.fn(); + mockUseUploadBulkExceptionsCsv.mockReturnValue({ + mutate: mockUploadCsv, + isPending: false, + } as unknown as ReturnType); + + mockUploadCsv.mockImplementation((_data, options) => { + if (options?.onSuccess) { + options.onSuccess({ success: ['user1', 'user2'], errors: [] }); + } + }); + + 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(); + }); + + // Switch to Bulk tab + const bulkTab = screen.getByRole('tab', { name: messages.bulkUploadTab.defaultMessage }); + await user.click(bulkTab); + + // Create and upload a mock file + const csvFile = new File(['username,notes\nuser1,note1'], 'test.csv', { type: 'text/csv' }); + const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement; + + if (fileInput) { + await user.upload(fileInput, csvFile); + await screen.findByText(/test\.csv/i); + + const saveButton = screen.getByText(messages.save.defaultMessage); + await user.click(saveButton); + + expect(mockUploadCsv).toHaveBeenCalled(); + } + }); + + it('handles CSV upload with partial errors', async () => { + const mockUploadCsv = jest.fn(); + mockUseUploadBulkExceptionsCsv.mockReturnValue({ + mutate: mockUploadCsv, + isPending: false, + } as unknown as ReturnType); + + mockUploadCsv.mockImplementation((_data, options) => { + if (options?.onSuccess) { + options.onSuccess({ + success: ['user1'], + errors: [{ learner: 'user2', message: 'User not found' }] + }); + } + }); + + 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(); + }); + + // Switch to Bulk tab + const bulkTab = screen.getByRole('tab', { name: messages.bulkUploadTab.defaultMessage }); + await user.click(bulkTab); + + const csvFile = new File(['username,notes\nuser1,note1'], 'test.csv', { type: 'text/csv' }); + const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement; + + if (fileInput) { + await user.upload(fileInput, csvFile); + await screen.findByText(/test\.csv/i); + + const saveButton = screen.getByText(messages.save.defaultMessage); + await user.click(saveButton); + + // Wait for modal to close and error modal to appear + await waitFor(() => { + expect(screen.queryByText(messages.grantExceptionsModalTitle.defaultMessage)).not.toBeInTheDocument(); + }); + } + }); + + it('handles CSV upload error', async () => { + const mockUploadCsv = jest.fn(); + mockUseUploadBulkExceptionsCsv.mockReturnValue({ + mutate: mockUploadCsv, + isPending: false, + } as unknown as ReturnType); + + mockUploadCsv.mockImplementation((_data, options) => { + if (options?.onError) { + options.onError({ response: { data: { message: 'CSV upload failed' } } }); + } + }); + + 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(); + }); + + // Switch to Bulk tab + const bulkTab = screen.getByRole('tab', { name: messages.bulkUploadTab.defaultMessage }); + await user.click(bulkTab); + + const csvFile = new File(['username,notes\nuser1,note1'], 'test.csv', { type: 'text/csv' }); + const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement; + + if (fileInput) { + await user.upload(fileInput, csvFile); + await screen.findByText(/test\.csv/i); + + const saveButton = screen.getByText(messages.save.defaultMessage); + await user.click(saveButton); + + expect(mockUploadCsv).toHaveBeenCalled(); + } + }); }); describe('Invalidate Certificate', () => { @@ -405,17 +542,17 @@ describe('CertificatesPage', () => { await user.click(invalidateButton); await waitFor(() => { - expect(screen.getByText(messages.invalidateCertificateModalTitle.defaultMessage)).toBeInTheDocument(); + expect(screen.getByRole('dialog')).toBeInTheDocument(); }); - const learnersInput = screen.getByPlaceholderText(messages.learnersPlaceholder.defaultMessage); + const learnerInput = screen.getByPlaceholderText(messages.learnerPlaceholder.defaultMessage); const notesInput = screen.getByPlaceholderText(messages.notesPlaceholder.defaultMessage); - await user.type(learnersInput, 'user1'); + await user.type(learnerInput, 'user1'); await user.type(notesInput, 'Invalid certificate'); - const submitButton = screen.getByText(messages.submit.defaultMessage); - await user.click(submitButton); + const saveButton = screen.getByText(messages.save.defaultMessage); + await user.click(saveButton); expect(mockInvalidateCert).toHaveBeenCalledWith( { learners: ['user1'], notes: 'Invalid certificate' }, @@ -440,18 +577,18 @@ describe('CertificatesPage', () => { await user.click(invalidateButton); await waitFor(() => { - expect(screen.getByText(messages.invalidateCertificateModalTitle.defaultMessage)).toBeInTheDocument(); + expect(screen.getByRole('dialog')).toBeInTheDocument(); }); - const learnersInput = screen.getByPlaceholderText(messages.learnersPlaceholder.defaultMessage); - await user.type(learnersInput, 'user1'); + const learnerInput = screen.getByPlaceholderText(messages.learnerPlaceholder.defaultMessage); + await user.type(learnerInput, 'user1'); - const submitButton = screen.getByText(messages.submit.defaultMessage); - await user.click(submitButton); + const saveButton = screen.getByText(messages.save.defaultMessage); + await user.click(saveButton); // Verify modal is closed (side effect of onSuccess) await waitFor(() => { - expect(screen.queryByText(messages.invalidateCertificateModalTitle.defaultMessage)).not.toBeInTheDocument(); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); }); }); @@ -470,14 +607,14 @@ describe('CertificatesPage', () => { await user.click(invalidateButton); await waitFor(() => { - expect(screen.getByText(messages.invalidateCertificateModalTitle.defaultMessage)).toBeInTheDocument(); + expect(screen.getByRole('dialog')).toBeInTheDocument(); }); - const learnersInput = screen.getByPlaceholderText(messages.learnersPlaceholder.defaultMessage); - await user.type(learnersInput, 'user1'); + const learnerInput = screen.getByPlaceholderText(messages.learnerPlaceholder.defaultMessage); + await user.type(learnerInput, 'user1'); - const submitButton = screen.getByText(messages.submit.defaultMessage); - await user.click(submitButton); + const saveButton = screen.getByText(messages.save.defaultMessage); + await user.click(saveButton); expect(mockInvalidateCert).toHaveBeenCalled(); }); @@ -499,57 +636,62 @@ describe('CertificatesPage', () => { await user.click(invalidateButton); await waitFor(() => { - expect(screen.getByText(messages.invalidateCertificateModalTitle.defaultMessage)).toBeInTheDocument(); + expect(screen.getByRole('dialog')).toBeInTheDocument(); }); - const learnersInput = screen.getByPlaceholderText(messages.learnersPlaceholder.defaultMessage); - await user.type(learnersInput, 'user1, user2'); + const learnerInput = screen.getByPlaceholderText(messages.learnerPlaceholder.defaultMessage); + await user.type(learnerInput, 'user1'); - const submitButton = screen.getByText(messages.submit.defaultMessage); - await user.click(submitButton); + const saveButton = screen.getByText(messages.save.defaultMessage); + await user.click(saveButton); + // Verify error modal is shown await waitFor(() => { - expect(screen.queryByText(messages.invalidateCertificateModalTitle.defaultMessage)).not.toBeInTheDocument(); + expect(screen.getByText(/Some invalidations failed/)).toBeInTheDocument(); }); }); }); describe('Toggle Certificate Generation', () => { - it('opens disable certificates modal when button is clicked', async () => { + it('opens student generated certificates modal when menu item is clicked', async () => { renderWithAlertAndIntl(); const user = userEvent.setup(); // Click the dropdown toggle button - const dropdownToggle = screen.getByRole('button', { name: messages.disableCertificatesButton.defaultMessage }); + const dropdownToggle = screen.getByRole('button', { name: 'More actions' }); await user.click(dropdownToggle); - // Click the disable certificates menu item - const disableMenuItem = screen.getByText(messages.disableCertificatesButton.defaultMessage); - await user.click(disableMenuItem); + // Click the student generated certificates menu item + const menuItem = screen.getByText(messages.studentGeneratedCertificatesMenuItem.defaultMessage); + await user.click(menuItem); await waitFor(() => { expect(screen.getByRole('dialog')).toBeInTheDocument(); }); }); - it('calls mutation to disable generation on confirm', async () => { + it('calls mutation when save is clicked after toggling checkbox', async () => { renderWithAlertAndIntl(); const user = userEvent.setup(); // Click the dropdown toggle button - const dropdownToggle = screen.getByRole('button', { name: messages.disableCertificatesButton.defaultMessage }); + const dropdownToggle = screen.getByRole('button', { name: 'More actions' }); await user.click(dropdownToggle); - // Click the disable certificates menu item - const disableMenuItem = screen.getByText(messages.disableCertificatesButton.defaultMessage); - await user.click(disableMenuItem); + // Click the student generated certificates menu item + const menuItem = screen.getByText(messages.studentGeneratedCertificatesMenuItem.defaultMessage); + await user.click(menuItem); await waitFor(() => { expect(screen.getByRole('dialog')).toBeInTheDocument(); }); - const confirmButton = screen.getByText(messages.confirm.defaultMessage); - await user.click(confirmButton); + // Toggle the checkbox + const checkbox = screen.getByRole('checkbox'); + await user.click(checkbox); + + const saveButton = screen.getByText(messages.save.defaultMessage); + await user.click(saveButton); expect(mockToggleGeneration).toHaveBeenCalledWith( false, @@ -571,19 +713,23 @@ describe('CertificatesPage', () => { const user = userEvent.setup(); // Click the dropdown toggle button - const dropdownToggle = screen.getByRole('button', { name: messages.disableCertificatesButton.defaultMessage }); + const dropdownToggle = screen.getByRole('button', { name: 'More actions' }); await user.click(dropdownToggle); - // Click the disable certificates menu item - const disableMenuItem = screen.getByText(messages.disableCertificatesButton.defaultMessage); - await user.click(disableMenuItem); + // Click the student generated certificates menu item + const menuItem = screen.getByText(messages.studentGeneratedCertificatesMenuItem.defaultMessage); + await user.click(menuItem); await waitFor(() => { expect(screen.getByRole('dialog')).toBeInTheDocument(); }); - const confirmButton = screen.getByText(messages.confirm.defaultMessage); - await user.click(confirmButton); + // Toggle the checkbox + const checkbox = screen.getByRole('checkbox'); + await user.click(checkbox); + + const saveButton = screen.getByText(messages.save.defaultMessage); + await user.click(saveButton); // Verify modal is closed (side effect of onSuccess) await waitFor(() => { @@ -603,19 +749,23 @@ describe('CertificatesPage', () => { const user = userEvent.setup(); // Click the dropdown toggle button - const dropdownToggle = screen.getByRole('button', { name: messages.disableCertificatesButton.defaultMessage }); + const dropdownToggle = screen.getByRole('button', { name: 'More actions' }); await user.click(dropdownToggle); - // Click the disable certificates menu item - const disableMenuItem = screen.getByText(messages.disableCertificatesButton.defaultMessage); - await user.click(disableMenuItem); + // Click the student generated certificates menu item + const menuItem = screen.getByText(messages.studentGeneratedCertificatesMenuItem.defaultMessage); + await user.click(menuItem); await waitFor(() => { expect(screen.getByRole('dialog')).toBeInTheDocument(); }); - const confirmButton = screen.getByText(messages.confirm.defaultMessage); - await user.click(confirmButton); + // Toggle the checkbox + const checkbox = screen.getByRole('checkbox'); + await user.click(checkbox); + + const saveButton = screen.getByText(messages.save.defaultMessage); + await user.click(saveButton); expect(mockToggleGeneration).toHaveBeenCalled(); }); @@ -937,33 +1087,33 @@ describe('CertificatesPage', () => { await user.click(invalidateButton); await waitFor(() => { - expect(screen.getByText(messages.invalidateCertificateModalTitle.defaultMessage)).toBeInTheDocument(); + expect(screen.getByRole('dialog')).toBeInTheDocument(); }); const closeButton = screen.getByLabelText('Close'); await user.click(closeButton); await waitFor(() => { - expect(screen.queryByText(messages.invalidateCertificateModalTitle.defaultMessage)).not.toBeInTheDocument(); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); }); }); - it('closes disable certificates modal', async () => { + it('closes student generated certificates modal', async () => { renderWithAlertAndIntl(); const user = userEvent.setup(); - const dropdownToggle = screen.getByRole('button', { name: messages.disableCertificatesButton.defaultMessage }); + const dropdownToggle = screen.getByRole('button', { name: 'More actions' }); await user.click(dropdownToggle); - const disableMenuItem = screen.getByText(messages.disableCertificatesButton.defaultMessage); - await user.click(disableMenuItem); + const menuItem = screen.getByText(messages.studentGeneratedCertificatesMenuItem.defaultMessage); + await user.click(menuItem); await waitFor(() => { expect(screen.getByRole('dialog')).toBeInTheDocument(); }); - const cancelButton = screen.getByText(messages.cancel.defaultMessage); - await user.click(cancelButton); + const closeButton = screen.getByText(messages.close.defaultMessage); + await user.click(closeButton); await waitFor(() => { expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); diff --git a/src/certificates/CertificatesPage.tsx b/src/certificates/CertificatesPage.tsx index c8eabf4c..ef2b6c3a 100644 --- a/src/certificates/CertificatesPage.tsx +++ b/src/certificates/CertificatesPage.tsx @@ -21,6 +21,7 @@ import { useRemoveException, useRemoveInvalidation, useToggleCertificateGeneration, + useUploadBulkExceptionsCsv, } from '@src/certificates/data/apiHook'; import { CertificateFilter } from '@src/certificates/types'; import { CERTIFICATES_PAGE_SIZE, TAB_KEYS, MODAL_TITLES, ALERT_VARIANTS } from '@src/certificates/constants'; @@ -68,6 +69,7 @@ const CertificatesPage = () => { }); const { mutate: grantExceptions, isPending: isGrantingExceptions } = useGrantBulkExceptions(courseId); + const { mutate: uploadCsvExceptions, isPending: isUploadingCsv } = useUploadBulkExceptionsCsv(courseId); const { mutate: invalidateCert, isPending: isInvalidating } = useInvalidateCertificate(courseId); const { mutate: removeExcept, isPending: isRemovingException } = useRemoveException(courseId); const { mutate: removeInval, isPending: isRemovingInvalidation } = useRemoveInvalidation(courseId); @@ -83,7 +85,7 @@ const CertificatesPage = () => { if (data.errors && data.errors.length > 0) { const errorMessages = data.errors.map(err => `${err.learner}: ${err.message}`).join('\n'); showModal({ - title: MODAL_TITLES.ERROR, + title: intl.formatMessage(messages.errorModalTitle), message: `Some exceptions failed:\n${errorMessages}`, variant: ALERT_VARIANTS.WARNING, }); @@ -94,7 +96,7 @@ const CertificatesPage = () => { }, onError: (error) => { showModal({ - title: MODAL_TITLES.ERROR, + title: intl.formatMessage(messages.errorModalTitle), message: getErrorMessage(error, intl.formatMessage(messages.errorGrantException)), variant: ALERT_VARIANTS.DANGER, }); @@ -103,6 +105,35 @@ const CertificatesPage = () => { ); }, [grantExceptions, showToast, showModal, intl]); + const handleUploadCsvExceptions = useCallback((file: File) => { + uploadCsvExceptions( + file, + { + onSuccess: (data) => { + setIsGrantExceptionsOpen(false); + if (data.errors && data.errors.length > 0) { + const errorMessages = data.errors.map(err => `${err.learner}: ${err.message}`).join('\n'); + showModal({ + title: intl.formatMessage(messages.errorModalTitle), + message: `Some exceptions failed:\n${errorMessages}`, + variant: ALERT_VARIANTS.WARNING, + }); + } + if (data.success && data.success.length > 0) { + showToast(intl.formatMessage(messages.exceptionsGrantedToast, { count: data.success.length })); + } + }, + onError: (error) => { + showModal({ + title: intl.formatMessage(messages.errorModalTitle), + message: getErrorMessage(error, intl.formatMessage(messages.errorGrantException)), + variant: ALERT_VARIANTS.DANGER, + }); + }, + }, + ); + }, [uploadCsvExceptions, showToast, showModal, intl]); + const handleInvalidateCertificate = useCallback((learners: string[], notes: string) => { invalidateCert( { learners, notes }, @@ -112,7 +143,7 @@ const CertificatesPage = () => { if (data.errors && data.errors.length > 0) { const errorMessages = data.errors.map(err => `${err.learner}: ${err.message}`).join('\n'); showModal({ - title: MODAL_TITLES.ERROR, + title: intl.formatMessage(messages.errorModalTitle), message: `Some invalidations failed:\n${errorMessages}`, variant: ALERT_VARIANTS.WARNING, }); @@ -123,7 +154,7 @@ const CertificatesPage = () => { }, onError: (error) => { showModal({ - title: MODAL_TITLES.ERROR, + title: intl.formatMessage(messages.errorModalTitle), message: getErrorMessage(error, intl.formatMessage(messages.errorInvalidateCertificate)), variant: ALERT_VARIANTS.DANGER, }); @@ -162,7 +193,7 @@ const CertificatesPage = () => { }, onError: (error) => { showModal({ - title: MODAL_TITLES.ERROR, + title: intl.formatMessage(messages.errorModalTitle), message: getErrorMessage(error, intl.formatMessage(messages.errorRemoveException)), variant: ALERT_VARIANTS.DANGER, }); @@ -201,7 +232,7 @@ const CertificatesPage = () => { }, onError: (error) => { showModal({ - title: MODAL_TITLES.ERROR, + title: intl.formatMessage(messages.errorModalTitle), message: getErrorMessage(error, intl.formatMessage(messages.errorRemoveInvalidation)), variant: ALERT_VARIANTS.DANGER, }); @@ -263,7 +294,7 @@ const CertificatesPage = () => { setIsGrantExceptionsOpen(true)} onInvalidateCertificate={() => setIsInvalidateCertificateOpen(true)} - onDisableCertificates={() => setIsDisableCertificatesOpen(true)} + onStudentGeneratedCertificates={() => setIsDisableCertificatesOpen(true)} /> @@ -309,7 +340,8 @@ const CertificatesPage = () => { isOpen={isGrantExceptionsOpen} onClose={() => setIsGrantExceptionsOpen(false)} onSubmit={handleGrantExceptions} - isSubmitting={isGrantingExceptions} + onUploadCsv={handleUploadCsvExceptions} + isSubmitting={isGrantingExceptions || isUploadingCsv} /> void, onInvalidateCertificate: () => void, - onDisableCertificates: () => void, + onStudentGeneratedCertificates?: () => void, } const CertificatesPageHeader = ({ onGrantExceptions, onInvalidateCertificate, - onDisableCertificates, + onStudentGeneratedCertificates, }: CertificatesPageHeaderProps) => { const intl = useIntl(); @@ -24,18 +24,20 @@ const CertificatesPageHeader = ({ - - {intl.formatMessage(messages.disableCertificatesButton)} - + {onStudentGeneratedCertificates && ( + + {intl.formatMessage(messages.studentGeneratedCertificatesMenuItem)} + + )} - diff --git a/src/certificates/components/GrantExceptionsModal.test.tsx b/src/certificates/components/GrantExceptionsModal.test.tsx index 0c5d9f3d..ffac1590 100644 --- a/src/certificates/components/GrantExceptionsModal.test.tsx +++ b/src/certificates/components/GrantExceptionsModal.test.tsx @@ -7,11 +7,13 @@ import messages from '@src/certificates/messages'; describe('GrantExceptionsModal', () => { const mockOnClose = jest.fn(); const mockOnSubmit = jest.fn(); + const mockOnUploadCsv = jest.fn(); const defaultProps = { isOpen: true, onClose: mockOnClose, onSubmit: mockOnSubmit, + onUploadCsv: mockOnUploadCsv, isSubmitting: false, }; @@ -31,39 +33,44 @@ describe('GrantExceptionsModal', () => { expect(screen.getByText(messages.grantExceptionsModalDescription.defaultMessage)).toBeInTheDocument(); }); - it('renders learners input field', () => { + it('renders Individual and Bulk tabs', () => { renderWithIntl(); - expect(screen.getByText(messages.learnersLabel.defaultMessage)).toBeInTheDocument(); - expect(screen.getByPlaceholderText(messages.learnersPlaceholder.defaultMessage)).toBeInTheDocument(); + expect(screen.getByRole('tab', { name: messages.singleLearnerTab.defaultMessage })).toBeInTheDocument(); + expect(screen.getByRole('tab', { name: messages.bulkUploadTab.defaultMessage })).toBeInTheDocument(); }); - it('renders notes input field', () => { + it('renders learner input field in Individual tab', () => { renderWithIntl(); - expect(screen.getByText(messages.notesLabel.defaultMessage)).toBeInTheDocument(); - expect(screen.getByPlaceholderText(messages.notesPlaceholder.defaultMessage)).toBeInTheDocument(); + expect(screen.getByPlaceholderText(messages.studentEmailUsername.defaultMessage)).toBeInTheDocument(); }); - it('renders submit and cancel buttons', () => { + it('renders notes input field in Individual tab', () => { renderWithIntl(); - expect(screen.getByRole('button', { name: messages.submit.defaultMessage })).toBeInTheDocument(); + expect(screen.getByPlaceholderText(messages.notesOptional.defaultMessage)).toBeInTheDocument(); + }); + + it('renders save and cancel buttons', () => { + renderWithIntl(); + + expect(screen.getByRole('button', { name: messages.save.defaultMessage })).toBeInTheDocument(); expect(screen.getByRole('button', { name: messages.cancel.defaultMessage })).toBeInTheDocument(); }); - it('calls onSubmit with learners and notes when form is submitted', async () => { + it('calls onSubmit with learner 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); + const learnerInput = screen.getByPlaceholderText(messages.studentEmailUsername.defaultMessage); + const notesInput = screen.getByPlaceholderText(messages.notesOptional.defaultMessage); - await user.type(learnersInput, 'user1@example.com'); + await user.type(learnerInput, 'user1@example.com'); await user.type(notesInput, 'Granting exception for completion'); - const submitButton = screen.getByRole('button', { name: messages.submit.defaultMessage }); - await user.click(submitButton); + const saveButton = screen.getByRole('button', { name: messages.save.defaultMessage }); + await user.click(saveButton); expect(mockOnSubmit).toHaveBeenCalledWith(['user1@example.com'], 'Granting exception for completion'); }); @@ -81,10 +88,10 @@ describe('GrantExceptionsModal', () => { it('disables buttons when isSubmitting is true', () => { renderWithIntl(); - const submitButton = screen.getByRole('button', { name: messages.submit.defaultMessage }); + const saveButton = screen.getByRole('button', { name: messages.save.defaultMessage }); const cancelButton = screen.getByRole('button', { name: messages.cancel.defaultMessage }); - expect(submitButton).toBeDisabled(); + expect(saveButton).toBeDisabled(); expect(cancelButton).toBeDisabled(); }); @@ -94,16 +101,82 @@ describe('GrantExceptionsModal', () => { expect(screen.queryByText(messages.grantExceptionsModalTitle.defaultMessage)).not.toBeInTheDocument(); }); - it('handles multiple learners input', async () => { + it('renders CSV upload in Bulk tab', async () => { + renderWithIntl(); + const user = userEvent.setup(); + + // Click on Bulk tab + const bulkTab = screen.getByRole('tab', { name: messages.bulkUploadTab.defaultMessage }); + await user.click(bulkTab); + + // Check for CSV upload elements + expect(screen.getByText(messages.csvFileLabel.defaultMessage)).toBeInTheDocument(); + expect(screen.getByText(messages.csvInstructions.defaultMessage)).toBeInTheDocument(); + }); + + it('clears form and closes modal when cancel is clicked', async () => { renderWithIntl(); const user = userEvent.setup(); - const learnersInput = screen.getByPlaceholderText(messages.learnersPlaceholder.defaultMessage); - await user.type(learnersInput, 'user1, user2, user3'); + const learnerInput = screen.getByPlaceholderText(messages.studentEmailUsername.defaultMessage); + const notesInput = screen.getByPlaceholderText(messages.notesOptional.defaultMessage); + + await user.type(learnerInput, 'user@example.com'); + await user.type(notesInput, 'Test notes'); + + const cancelButton = screen.getByRole('button', { name: messages.cancel.defaultMessage }); + await user.click(cancelButton); + + expect(mockOnClose).toHaveBeenCalled(); + }); + + it('trims whitespace from learner input before submit', async () => { + renderWithIntl(); + const user = userEvent.setup(); + + const learnerInput = screen.getByPlaceholderText(messages.studentEmailUsername.defaultMessage); + await user.type(learnerInput, ' user@example.com '); + + const saveButton = screen.getByRole('button', { name: messages.save.defaultMessage }); + await user.click(saveButton); + + expect(mockOnSubmit).toHaveBeenCalledWith(['user@example.com'], ''); + }); + + it('does not call onUploadCsv when no file is selected', async () => { + renderWithIntl(); + const user = userEvent.setup(); + + const bulkTab = screen.getByRole('tab', { name: messages.bulkUploadTab.defaultMessage }); + await user.click(bulkTab); + + const saveButton = screen.getByRole('button', { name: messages.save.defaultMessage }); + expect(saveButton).toBeDisabled(); + }); + + it('calls onUploadCsv when CSV file is uploaded and submitted', async () => { + renderWithIntl(); + const user = userEvent.setup(); + + const bulkTab = screen.getByRole('tab', { name: messages.bulkUploadTab.defaultMessage }); + await user.click(bulkTab); + + // Create a mock file + const csvFile = new File(['username,notes\nuser1,note1'], 'test.csv', { type: 'text/csv' }); + + // Find the file input + const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement; + + if (fileInput) { + await user.upload(fileInput, csvFile); + + // Wait for file to be processed + await screen.findByText(/test\.csv/i); - const submitButton = screen.getByRole('button', { name: messages.submit.defaultMessage }); - await user.click(submitButton); + const saveButton = screen.getByRole('button', { name: messages.save.defaultMessage }); + await user.click(saveButton); - expect(mockOnSubmit).toHaveBeenCalledWith(['user1', 'user2', 'user3'], ''); + expect(mockOnUploadCsv).toHaveBeenCalledWith(csvFile); + } }); }); diff --git a/src/certificates/components/GrantExceptionsModal.tsx b/src/certificates/components/GrantExceptionsModal.tsx index ee4a0d32..9f4b2fee 100644 --- a/src/certificates/components/GrantExceptionsModal.tsx +++ b/src/certificates/components/GrantExceptionsModal.tsx @@ -1,11 +1,14 @@ +import { useState } from 'react'; import { useIntl } from '@openedx/frontend-base'; -import LearnerActionModal from '@src/certificates/components/LearnerActionModal'; +import { ActionRow, Button, Dropzone, Form, Hyperlink, Icon, ModalDialog, OverlayTrigger, Tab, Tabs, Tooltip } from '@openedx/paragon'; +import { InfoOutline } from '@openedx/paragon/icons'; import messages from '@src/certificates/messages'; interface GrantExceptionsModalProps { isOpen: boolean, onClose: () => void, onSubmit: (learners: string[], notes: string) => void, + onUploadCsv: (file: File) => void, isSubmitting: boolean, } @@ -13,25 +16,150 @@ const GrantExceptionsModal = ({ isOpen, onClose, onSubmit, + onUploadCsv, isSubmitting, }: GrantExceptionsModalProps) => { const intl = useIntl(); + const [learner, setLearner] = useState(''); + const [notes, setNotes] = useState(''); + const [csvFileName, setCsvFileName] = useState(''); + const [csvFile, setCsvFile] = useState(null); + const [activeTab, setActiveTab] = useState('single'); + + const handleSubmit = () => { + if (activeTab === 'single') { + const trimmedLearner = learner.trim(); + if (trimmedLearner) { + onSubmit([trimmedLearner], notes); + setLearner(''); + setNotes(''); + } + } else { + // Handle CSV upload + if (csvFile) { + onUploadCsv(csvFile); + setCsvFileName(''); + setCsvFile(null); + } + } + }; + + const handleClose = () => { + setLearner(''); + setNotes(''); + setCsvFileName(''); + setCsvFile(null); + setActiveTab('single'); + onClose(); + }; return ( - + className="grant-exceptions-modal" + isOverflowVisible={false} + > + + {intl.formatMessage(messages.grantExceptionsModalTitle)} + + +

{intl.formatMessage(messages.grantExceptionsModalDescription)}

+ setActiveTab(key as string)} + id="grant-exceptions-tabs" + className="mb-3" + > + +
+

{intl.formatMessage(messages.individualTabDescription)}

+ + setLearner(e.target.value)} + /> + + + setNotes(e.target.value)} + /> + +
+
+ +
+ {intl.formatMessage(messages.csvFileLabel)} + {csvFileName && ( +
+ {intl.formatMessage(messages.csvFileSelected, { fileName: csvFileName })} +
+ )} + { + const file = fileData.get('file') || fileData.get('files[0]') || Array.from(fileData.values()).find(value => value instanceof File); + + if (file instanceof File) { + setCsvFileName(file.name); + setCsvFile(file); + } else if (handleError) { + handleError(new Error('No file found')); + } + }} + /> +
+ + {intl.formatMessage(messages.csvInstructionsTooltip)} + + )} + > + + + { + e.preventDefault(); + // TODO: Link to CSV instructions documentation + }} + className="text-muted text-decoration-none" + > + {intl.formatMessage(messages.csvInstructions)} + + + +
+
+
+
+
+ + + + + + + ); }; diff --git a/src/certificates/components/InvalidateCertificateModal.test.tsx b/src/certificates/components/InvalidateCertificateModal.test.tsx index bc08845c..1654e6e5 100644 --- a/src/certificates/components/InvalidateCertificateModal.test.tsx +++ b/src/certificates/components/InvalidateCertificateModal.test.tsx @@ -31,11 +31,11 @@ describe('InvalidateCertificateModal', () => { expect(screen.getByText(messages.invalidateCertificateModalDescription.defaultMessage)).toBeInTheDocument(); }); - it('renders learners input field', () => { + it('renders learner input field', () => { renderWithIntl(); - expect(screen.getByText(messages.learnersLabel.defaultMessage)).toBeInTheDocument(); - expect(screen.getByPlaceholderText(messages.learnersPlaceholder.defaultMessage)).toBeInTheDocument(); + expect(screen.getByText(messages.learnerLabel.defaultMessage)).toBeInTheDocument(); + expect(screen.getByPlaceholderText(messages.learnerPlaceholder.defaultMessage)).toBeInTheDocument(); }); it('renders notes input field', () => { @@ -45,21 +45,21 @@ describe('InvalidateCertificateModal', () => { expect(screen.getByPlaceholderText(messages.notesPlaceholder.defaultMessage)).toBeInTheDocument(); }); - it('calls onSubmit with learners and notes when form is submitted', async () => { + it('calls onSubmit with learner and notes when form is submitted', async () => { renderWithIntl(); const user = userEvent.setup(); - const learnersInput = screen.getByPlaceholderText(messages.learnersPlaceholder.defaultMessage); + const learnerInput = screen.getByPlaceholderText(messages.learnerPlaceholder.defaultMessage); const notesInput = screen.getByPlaceholderText(messages.notesPlaceholder.defaultMessage); - await user.type(learnersInput, 'user1@example.com, user2@example.com'); + await user.type(learnerInput, 'user1@example.com'); await user.type(notesInput, 'Certificate invalidated due to violation'); - const submitButton = screen.getByRole('button', { name: messages.submit.defaultMessage }); - await user.click(submitButton); + const saveButton = screen.getByRole('button', { name: messages.save.defaultMessage }); + await user.click(saveButton); expect(mockOnSubmit).toHaveBeenCalledWith( - ['user1@example.com', 'user2@example.com'], + ['user1@example.com'], 'Certificate invalidated due to violation' ); }); @@ -77,10 +77,10 @@ describe('InvalidateCertificateModal', () => { it('disables buttons when isSubmitting is true', () => { renderWithIntl(); - const submitButton = screen.getByRole('button', { name: messages.submit.defaultMessage }); + const saveButton = screen.getByRole('button', { name: messages.save.defaultMessage }); const cancelButton = screen.getByRole('button', { name: messages.cancel.defaultMessage }); - expect(submitButton).toBeDisabled(); + expect(saveButton).toBeDisabled(); expect(cancelButton).toBeDisabled(); }); @@ -90,22 +90,22 @@ describe('InvalidateCertificateModal', () => { expect(screen.queryByText(messages.invalidateCertificateModalTitle.defaultMessage)).not.toBeInTheDocument(); }); - it('submit button is disabled when learners field is empty', () => { + it('save button is disabled when learner field is empty', () => { renderWithIntl(); - const submitButton = screen.getByRole('button', { name: messages.submit.defaultMessage }); - expect(submitButton).toBeDisabled(); + const saveButton = screen.getByRole('button', { name: messages.save.defaultMessage }); + expect(saveButton).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 learnerInput = screen.getByPlaceholderText(messages.learnerPlaceholder.defaultMessage); + await user.type(learnerInput, 'user1@example.com'); - const submitButton = screen.getByRole('button', { name: messages.submit.defaultMessage }); - await user.click(submitButton); + const saveButton = screen.getByRole('button', { name: messages.save.defaultMessage }); + await user.click(saveButton); expect(mockOnSubmit).toHaveBeenCalledWith(['user1@example.com'], ''); }); diff --git a/src/certificates/components/InvalidateCertificateModal.tsx b/src/certificates/components/InvalidateCertificateModal.tsx index 8a1c1a63..f68d1181 100644 --- a/src/certificates/components/InvalidateCertificateModal.tsx +++ b/src/certificates/components/InvalidateCertificateModal.tsx @@ -1,5 +1,6 @@ +import { useState } from 'react'; import { useIntl } from '@openedx/frontend-base'; -import LearnerActionModal from '@src/certificates/components/LearnerActionModal'; +import { ActionRow, Button, Form, ModalDialog } from '@openedx/paragon'; import messages from '@src/certificates/messages'; interface InvalidateCertificateModalProps { @@ -16,22 +17,69 @@ const InvalidateCertificateModal = ({ isSubmitting, }: InvalidateCertificateModalProps) => { const intl = useIntl(); + const [learner, setLearner] = useState(''); + const [notes, setNotes] = useState(''); + + const handleSubmit = () => { + const trimmedLearner = learner.trim(); + if (trimmedLearner) { + onSubmit([trimmedLearner], notes); + setLearner(''); + setNotes(''); + } + }; + + const handleClose = () => { + setLearner(''); + setNotes(''); + onClose(); + }; return ( - + className="invalidate-certificate-modal" + isOverflowVisible={false} + > + + {intl.formatMessage(messages.invalidateCertificateModalTitle)} + + +

{intl.formatMessage(messages.invalidateCertificateModalDescription)}

+ + {intl.formatMessage(messages.learnerLabel)} + setLearner(e.target.value)} + /> + + + {intl.formatMessage(messages.notesLabel)} + setNotes(e.target.value)} + /> + +
+ + + + + + + ); }; diff --git a/src/certificates/data/api.test.ts b/src/certificates/data/api.test.ts index 834adec9..9d90db9f 100644 --- a/src/certificates/data/api.test.ts +++ b/src/certificates/data/api.test.ts @@ -4,6 +4,7 @@ import { getIssuedCertificates, getInstructorTasks, grantBulkExceptions, + uploadBulkExceptionsCsv, invalidateCertificate, removeException, removeInvalidation, @@ -191,6 +192,36 @@ describe('Certificate API', () => { }); }); + describe('uploadBulkExceptionsCsv', () => { + it('uploads CSV file for bulk exceptions', async () => { + mockPost.mockResolvedValue({ data: { success: ['user1', 'user2'], errors: [] } }); + + const csvFile = new File(['username,notes\nuser1,note1'], 'test.csv', { type: 'text/csv' }); + + await uploadBulkExceptionsCsv('course-v1:edX+Test+2024', csvFile); + + expect(mockPost).toHaveBeenCalledWith( + 'http://localhost:18000/api/instructor/v2/courses/course-v1:edX+Test+2024/certificates/exceptions/bulk', + expect.any(FormData), + { + headers: { + 'Content-Type': 'multipart/form-data', + }, + } + ); + }); + + it('handles errors when uploading CSV', async () => { + mockPost.mockRejectedValue(new Error('Invalid CSV format')); + + const csvFile = new File(['invalid'], 'test.csv', { type: 'text/csv' }); + + await expect( + uploadBulkExceptionsCsv('course-v1:edX+Test+2024', csvFile) + ).rejects.toThrow('Invalid CSV format'); + }); + }); + describe('invalidateCertificate', () => { it('invalidates certificates for learners', async () => { mockPost.mockResolvedValue({ data: { success: ['user1', 'user2'], errors: [] } }); diff --git a/src/certificates/data/api.ts b/src/certificates/data/api.ts index f099ea49..4bd1d38f 100644 --- a/src/certificates/data/api.ts +++ b/src/certificates/data/api.ts @@ -60,6 +60,25 @@ export const grantBulkExceptions = async ( return camelCaseObject(data); }; +export const uploadBulkExceptionsCsv = async ( + courseId: string, + file: File, +): Promise<{ success: string[], errors: { learner: string, message: string }[] }> => { + const formData = new FormData(); + formData.append('file', file); + + const { data } = await getAuthenticatedHttpClient().post( + `${getApiBaseUrl()}/api/instructor/v2/courses/${courseId}/certificates/exceptions/bulk`, + formData, + { + headers: { + 'Content-Type': 'multipart/form-data', + }, + }, + ); + return camelCaseObject(data); +}; + export const invalidateCertificate = async ( courseId: string, request: InvalidateCertificateRequest, diff --git a/src/certificates/data/apiHook.test.ts b/src/certificates/data/apiHook.test.ts index 46e37e10..a0f86af4 100644 --- a/src/certificates/data/apiHook.test.ts +++ b/src/certificates/data/apiHook.test.ts @@ -5,6 +5,7 @@ import { useIssuedCertificates, useInstructorTasks, useGrantBulkExceptions, + useUploadBulkExceptionsCsv, useInvalidateCertificate, useRemoveException, useRemoveInvalidation, @@ -16,6 +17,7 @@ import { getIssuedCertificates, getInstructorTasks, grantBulkExceptions, + uploadBulkExceptionsCsv, invalidateCertificate, removeException, removeInvalidation, @@ -30,6 +32,7 @@ jest.mock('@src/certificates/data/api'); const mockGetIssuedCertificates = getIssuedCertificates as jest.MockedFunction; const mockGetInstructorTasks = getInstructorTasks as jest.MockedFunction; const mockGrantBulkExceptions = grantBulkExceptions as jest.MockedFunction; +const mockUploadBulkExceptionsCsv = uploadBulkExceptionsCsv as jest.MockedFunction; const mockInvalidateCertificate = invalidateCertificate as jest.MockedFunction; const mockRemoveException = removeException as jest.MockedFunction; const mockRemoveInvalidation = removeInvalidation as jest.MockedFunction; @@ -270,6 +273,49 @@ describe('certificates api hooks', () => { }); }); + describe('useUploadBulkExceptionsCsv', () => { + it('uploads CSV file successfully', async () => { + mockUploadBulkExceptionsCsv.mockResolvedValue({ success: ['user1', 'user2'], errors: [] }); + + const { Wrapper, queryClient: qc } = createWrapper(); + queryClient = qc; + + const { result } = renderHook(() => useUploadBulkExceptionsCsv('course-v1:Test+Course+2024'), { + wrapper: Wrapper, + }); + + const csvFile = new File(['username,notes\nuser1,note1'], 'test.csv', { type: 'text/csv' }); + result.current.mutate(csvFile); + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true); + }); + + expect(mockUploadBulkExceptionsCsv).toHaveBeenCalledWith('course-v1:Test+Course+2024', csvFile); + }); + + it('handles error when uploading CSV', async () => { + const mockError = new Error('Invalid CSV format'); + mockUploadBulkExceptionsCsv.mockRejectedValue(mockError); + + const { Wrapper, queryClient: qc } = createWrapper(); + queryClient = qc; + + const { result } = renderHook(() => useUploadBulkExceptionsCsv('course-v1:Test+Course+2024'), { + wrapper: Wrapper, + }); + + const csvFile = new File(['invalid'], 'test.csv', { type: 'text/csv' }); + result.current.mutate(csvFile); + + await waitFor(() => { + expect(result.current.isError).toBe(true); + }); + + expect(result.current.error).toBe(mockError); + }); + }); + describe('useInvalidateCertificate', () => { it('invalidates certificate successfully', async () => { mockInvalidateCertificate.mockResolvedValue({ success: ['user1'], errors: [] }); diff --git a/src/certificates/data/apiHook.ts b/src/certificates/data/apiHook.ts index 5e8727b9..72a0b617 100644 --- a/src/certificates/data/apiHook.ts +++ b/src/certificates/data/apiHook.ts @@ -17,6 +17,7 @@ import { removeException, removeInvalidation, toggleCertificateGeneration, + uploadBulkExceptionsCsv, } from '@src/certificates/data/api'; import { certificatesQueryKeys } from '@src/certificates/data/queryKeys'; @@ -66,6 +67,22 @@ export const useGrantBulkExceptions = (courseId: string) => { }); }; +/** + * Hook to upload bulk certificate exceptions via CSV + */ +export const useUploadBulkExceptionsCsv = (courseId: string) => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (file: File) => uploadBulkExceptionsCsv(courseId, file), + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: certificatesQueryKeys.byCourse(courseId), + exact: false, + }); + }, + }); +}; + /** * Hook to invalidate certificate */ diff --git a/src/certificates/messages.ts b/src/certificates/messages.ts index b4f34535..5210992c 100644 --- a/src/certificates/messages.ts +++ b/src/certificates/messages.ts @@ -173,22 +173,22 @@ const messages = defineMessages({ }, grantExceptionsModalTitle: { id: 'instruct.certificates.grantExceptionsModalTitle', - defaultMessage: 'Grant Certificate Exceptions', + defaultMessage: 'Add to Exceptions List', 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.', + defaultMessage: 'Set exceptions to generate certificates for learners who did not qualify for a certificate but have been given an exception by the course team. After you add learners to the exception list, you must Generate Exception Certificates.', description: 'Description for grant exceptions modal', }, invalidateCertificateModalTitle: { id: 'instruct.certificates.invalidateCertificateModalTitle', - defaultMessage: 'Invalidate Certificates', + defaultMessage: 'Invalidate Certificate', description: 'Title for invalidate certificate modal', }, invalidateCertificateModalDescription: { id: 'instruct.certificates.invalidateCertificateModalDescription', - defaultMessage: 'Enter usernames or emails, or upload a CSV file to invalidate certificates.', + defaultMessage: 'Enter username or email to invalidate a certificate.', description: 'Description for invalidate certificate modal', }, removeExceptionModalTitle: { @@ -251,6 +251,96 @@ const messages = defineMessages({ defaultMessage: 'Enter usernames or emails (one per line)', description: 'Placeholder for learners field', }, + learnerLabel: { + id: 'instruct.certificates.learnerLabel', + defaultMessage: 'Username or Email', + description: 'Label for single learner field', + }, + learnerPlaceholder: { + id: 'instruct.certificates.learnerPlaceholder', + defaultMessage: 'Enter username or email', + description: 'Placeholder for single learner field', + }, + bulkUploadTab: { + id: 'instruct.certificates.bulkUploadTab', + defaultMessage: 'Bulk', + description: 'Tab label for bulk CSV upload', + }, + singleLearnerTab: { + id: 'instruct.certificates.singleLearnerTab', + defaultMessage: 'Individual', + description: 'Tab label for single learner input', + }, + csvFileLabel: { + id: 'instruct.certificates.csvFileLabel', + defaultMessage: 'Upload CSV', + description: 'Label for CSV file upload', + }, + csvInstructions: { + id: 'instruct.certificates.csvInstructions', + defaultMessage: 'CSV Instructions', + description: 'Link text for CSV instructions', + }, + csvInstructionsTooltip: { + id: 'instruct.certificates.csvInstructionsTooltip', + defaultMessage: 'CSV file should contain the usernames or email addresses of learners who will receive exceptions. Include the username or email address in the first comma separated field. You can include an optional note describing the reason for the exception in the second comma separated field.', + description: 'Tooltip text for CSV instructions', + }, + csvFileSelected: { + id: 'instruct.certificates.csvFileSelected', + defaultMessage: 'File selected: {fileName}', + description: 'Message shown when CSV file is selected', + }, + csvUploadPending: { + id: 'instruct.certificates.csvUploadPending', + defaultMessage: 'CSV file "{fileName}" is ready. Bulk upload will be processed when the backend API is available.', + description: 'Message shown when CSV upload is pending backend implementation', + }, + studentGeneratedCertificatesMenuItem: { + id: 'instruct.certificates.studentGeneratedCertificatesMenuItem', + defaultMessage: 'Student Generated Certificates', + description: 'Menu item for student generated certificates', + }, + moreActionsButton: { + id: 'instruct.certificates.moreActionsButton', + defaultMessage: 'More actions', + description: 'Button for more actions menu', + }, + studentGeneratedCertificatesModalTitle: { + id: 'instruct.certificates.studentGeneratedCertificatesModalTitle', + defaultMessage: 'Student Generated Certificates', + description: 'Title for student generated certificates modal', + }, + enableStudentGeneratedCertificates: { + id: 'instruct.certificates.enableStudentGeneratedCertificates', + defaultMessage: 'Enable Student Generated Certificates', + description: 'Checkbox label for enabling student generated certificates', + }, + studentGeneratedCertificatesDescription: { + id: 'instruct.certificates.studentGeneratedCertificatesDescription', + defaultMessage: 'This functionality is enabled by default', + description: 'Description text for student generated certificates', + }, + close: { + id: 'instruct.certificates.close', + defaultMessage: 'Close', + description: 'Close button text', + }, + studentEmailUsername: { + id: 'instruct.certificates.studentEmailUsername', + defaultMessage: 'Student Email/Username', + description: 'Placeholder for student email or username field', + }, + notesOptional: { + id: 'instruct.certificates.notesOptional', + defaultMessage: 'Notes (optional)', + description: 'Placeholder for optional notes field', + }, + individualTabDescription: { + id: 'instruct.certificates.individualTabDescription', + defaultMessage: 'Enter the username or email address of each learner that you want to add as an exception.', + description: 'Description for individual tab', + }, cancel: { id: 'instruct.certificates.cancel', defaultMessage: 'Cancel', @@ -266,6 +356,11 @@ const messages = defineMessages({ defaultMessage: 'Submit', description: 'Submit button text', }, + save: { + id: 'instruct.certificates.save', + defaultMessage: 'Save', + description: 'Save button text', + }, columnTaskName: { id: 'instruct.certificates.columnTaskName', defaultMessage: 'Task Name', @@ -341,6 +436,11 @@ const messages = defineMessages({ defaultMessage: 'Regenerate Certificates: {filter}', description: 'Button to regenerate certificates with filter applied', }, + errorModalTitle: { + id: 'instruct.certificates.errorModalTitle', + defaultMessage: 'Error', + description: 'Title for error modal', + }, }); export default messages;