Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions src/app.scss
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
@use "@openedx/frontend-base/shell/app.scss";
@import "./certificates/certificates.scss";

html, body {
overflow-x: hidden;
}

.toast-container {
left: unset;
Expand Down
215 changes: 212 additions & 3 deletions src/certificates/CertificatesPage.tsx
Original file line number Diff line number Diff line change
@@ -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>(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 (
<div>
<h3>Certificates</h3>
</div>
<Container className="mt-4.5 mb-4" fluid>
<CertificatesPageHeader
onGrantExceptions={modalActions.openGrantExceptions}
onInvalidateCertificate={modalActions.openInvalidateCertificate}
onDisableCertificates={modalActions.openDisableCertificates}
/>

<Card variant="muted" className="pt-3 pt-md-4 pb-4 pb-md-6 certificates-card">
<Tabs
activeKey={activeTab}
onSelect={(key) => setActiveTab(key || TAB_KEYS.ISSUED)}
className="mx-4"
variant="button-group"
>
<Tab eventKey={TAB_KEYS.ISSUED} title={intl.formatMessage(messages.issuedCertificatesTab)}>
<IssuedCertificatesTab
data={filteredData}
isLoading={false}
itemCount={filteredData.length}
pageCount={Math.ceil(filteredData.length / CERTIFICATES_PAGE_SIZE)}
search={search}
onSearchChange={setSearch}
filter={filter}
onFilterChange={setFilter}
currentPage={certificatesPage}
onPageChange={setCertificatesPage}
onRemoveException={handleRemoveException}
onRemoveInvalidation={handleRemoveInvalidationClick}
onRegenerateCertificates={handleRegenerateCertificates}
/>
</Tab>
<Tab eventKey={TAB_KEYS.HISTORY} title={intl.formatMessage(messages.generationHistoryTab)}>
<div className="d-flex flex-column mt-3 mt-md-4">
<GenerationHistoryTable
data={tasksData?.results || []}
isLoading={isLoadingTasks}
itemCount={tasksData?.count || 0}
pageCount={tasksData?.numPages || 0}
currentPage={tasksPage}
onPageChange={setTasksPage}
/>
</div>
</Tab>
</Tabs>
</Card>

<GrantExceptionsModal
isOpen={modals.grantExceptions}
onClose={modalActions.closeGrantExceptions}
onSubmit={handleGrantExceptions}
isSubmitting={isGrantingExceptions}
/>
<InvalidateCertificateModal
isOpen={modals.invalidateCertificate}
onClose={modalActions.closeInvalidateCertificate}
onSubmit={handleInvalidateCertificate}
isSubmitting={isInvalidating}
/>
<RemoveInvalidationModal
isOpen={modals.removeInvalidation}
email={selectedEmail}
onClose={() => {
modalActions.closeRemoveInvalidation();
setSelectedUsername('');
setSelectedEmail('');
}}
onConfirm={handleRemoveInvalidationConfirm}
isSubmitting={isRemovingInvalidation}
/>
<DisableCertificatesModal
isOpen={modals.disableCertificates}
isEnabled={isCertificateGenerationEnabled}
onClose={modalActions.closeDisableCertificates}
onConfirm={handleToggleCertificateGeneration}
isSubmitting={isTogglingGeneration}
/>
</Container>
);
};

Expand Down
157 changes: 157 additions & 0 deletions src/certificates/__tests__/CertificatesPage.test.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof useInstructorTasks>;
const mockUseGrantBulkExceptions = useGrantBulkExceptions as jest.MockedFunction<typeof useGrantBulkExceptions>;
const mockUseInvalidateCertificate = useInvalidateCertificate as jest.MockedFunction<typeof useInvalidateCertificate>;
const mockUseRemoveException = useRemoveException as jest.MockedFunction<typeof useRemoveException>;
const mockUseRemoveInvalidation = useRemoveInvalidation as jest.MockedFunction<typeof useRemoveInvalidation>;
const mockUseToggleCertificateGeneration = useToggleCertificateGeneration as jest.MockedFunction<typeof useToggleCertificateGeneration>;

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<typeof useInstructorTasks>);

mockUseGrantBulkExceptions.mockReturnValue({
mutate: mockGrantExceptions,
isPending: false,
} as unknown as ReturnType<typeof useGrantBulkExceptions>);

mockUseInvalidateCertificate.mockReturnValue({
mutate: mockInvalidateCert,
isPending: false,
} as unknown as ReturnType<typeof useInvalidateCertificate>);

mockUseRemoveException.mockReturnValue({
mutate: mockRemoveException,
} as unknown as ReturnType<typeof useRemoveException>);

mockUseRemoveInvalidation.mockReturnValue({
mutate: mockRemoveInvalidation,
isPending: false,
} as unknown as ReturnType<typeof useRemoveInvalidation>);

mockUseToggleCertificateGeneration.mockReturnValue({
mutate: mockToggleGeneration,
isPending: false,
} as unknown as ReturnType<typeof useToggleCertificateGeneration>);
});

it('renders page with header and tabs', () => {
renderWithAlertAndIntl(<CertificatesPage />);

expect(screen.getByText(messages.issuedCertificatesTab.defaultMessage)).toBeInTheDocument();
expect(screen.getByText(messages.generationHistoryTab.defaultMessage)).toBeInTheDocument();
});

it('renders issued certificates tab by default', () => {
renderWithAlertAndIntl(<CertificatesPage />);

expect(screen.getByText(messages.issuedCertificatesTab.defaultMessage)).toBeInTheDocument();
});

it('switches to generation history tab', async () => {
renderWithAlertAndIntl(<CertificatesPage />);
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(<CertificatesPage />);

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(<CertificatesPage />);
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(<CertificatesPage />);
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(<CertificatesPage />);

expect(screen.getByText('user1')).toBeInTheDocument();
expect(screen.getByText('user1@example.com')).toBeInTheDocument();
});

it('fetches instructor tasks on mount', () => {
renderWithAlertAndIntl(<CertificatesPage />);

expect(mockUseInstructorTasks).toHaveBeenCalledWith(
'course-v1:edX+Test+2024',
{ page: 0, pageSize: 25 }
);
});
});
Loading
Loading