diff --git a/src/Main.tsx b/src/Main.tsx index fbf22253..2ea07584 100644 --- a/src/Main.tsx +++ b/src/Main.tsx @@ -6,16 +6,19 @@ import './main.scss'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { Outlet } from 'react-router-dom'; import { ReactQueryDevtools } from '@tanstack/react-query-devtools'; +import { ToastManagerProvider } from './providers/ToastManagerProvider'; const queryClient = new QueryClient(); const Main = () => ( -
- - { getAppConfig(appId).NODE_ENV === 'development' && } -
+ +
+ + { getAppConfig(appId).NODE_ENV === 'development' && } +
+
); diff --git a/src/components/ActionCard.test.tsx b/src/components/ActionCard.test.tsx new file mode 100644 index 00000000..3dd71e87 --- /dev/null +++ b/src/components/ActionCard.test.tsx @@ -0,0 +1,73 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { ActionCard } from './ActionCard'; + +describe('ActionCard', () => { + let user: ReturnType; + const defaultProps = { + title: 'Test Card Title', + description: 'This is a test card description', + buttonLabel: 'Click Me', + }; + + beforeEach(() => { + user = userEvent.setup(); + }); + + it('should render title and description correctly', () => { + render(); + expect(screen.getByText('Test Card Title')).toBeInTheDocument(); + expect(screen.getByText('This is a test card description')).toBeInTheDocument(); + }); + + it('should render button with correct label and call onClick when clicked', async () => { + const mockOnClick = jest.fn(); + render( + + ); + const button = screen.getByRole('button', { name: 'Click Me' }); + expect(button).toBeInTheDocument(); + expect(button).not.toBeDisabled(); + await user.click(button); + expect(mockOnClick).toHaveBeenCalledTimes(1); + }); + + it('should disable button when isLoading is true', () => { + render( + + ); + const button = screen.getByRole('button', { name: 'Click Me' }); + expect(button).toBeDisabled(); + }); + + it('should render custom action instead of default button when provided', () => { + const CustomAction = () => ( +
+ + +
+ ); + render( + } + /> + ); + expect(screen.getByText('Custom Button 1')).toBeInTheDocument(); + expect(screen.getByText('Custom Button 2')).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Click Me' })).not.toBeInTheDocument(); + }); + + it('should handle missing onButtonClick prop gracefully', async () => { + render(); + const button = screen.getByRole('button', { name: 'Click Me' }); + await user.click(button); + expect(button).toBeInTheDocument(); + }); +}); diff --git a/src/components/ActionCard.tsx b/src/components/ActionCard.tsx new file mode 100644 index 00000000..5a764bbc --- /dev/null +++ b/src/components/ActionCard.tsx @@ -0,0 +1,43 @@ +import { Button, Card } from '@openedx/paragon'; + +interface ActionCardProps { + title: string, + description: string, + buttonLabel: string, + onButtonClick?: () => void, + customAction?: React.ReactNode, + isLoading?: boolean, +} + +const ActionCard = ({ + title, + description, + buttonLabel, + onButtonClick, + customAction, + isLoading = false +}: ActionCardProps) => { + return ( + + + +

{title}

+

{description}

+
+
+ + {customAction ?? ( + + )} + +
+ ); +}; + +export { ActionCard }; diff --git a/src/constants.ts b/src/constants.ts index a4eca2dd..68b1266a 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -1 +1,2 @@ export const appId = 'org.openedx.frontend.app.instructor'; +export const DEFAULT_TOAST_DELAY = 5000; // in milliseconds diff --git a/src/data/api.ts b/src/data/api.ts index 7a94095d..e42b2059 100644 --- a/src/data/api.ts +++ b/src/data/api.ts @@ -3,4 +3,4 @@ import { camelCaseObject, getAppConfig, getAuthenticatedHttpClient } from '@openedx/frontend-base'; import { appId } from '../constants'; -const getApiBaseUrl = () => getAppConfig(appId).LMS_BASE_URL as string; +export const getApiBaseUrl = () => getAppConfig(appId).LMS_BASE_URL as string; diff --git a/src/dataDownloads/DataDownloadsPage.test.tsx b/src/dataDownloads/DataDownloadsPage.test.tsx new file mode 100644 index 00000000..b3d55f2c --- /dev/null +++ b/src/dataDownloads/DataDownloadsPage.test.tsx @@ -0,0 +1,66 @@ +import { screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { DataDownloadsPage } from './DataDownloadsPage'; +import { useGeneratedReports, useGenerateReportLink, useTriggerReportGeneration } from './data/apiHook'; +import { renderWithProviders } from '../testUtils'; + +// Mock ResizeObserver +global.ResizeObserver = jest.fn().mockImplementation(() => ({ + observe: jest.fn(), + unobserve: jest.fn(), + disconnect: jest.fn(), +})); + +jest.mock('./data/apiHook'); + +const mockUseGeneratedReports = useGeneratedReports as jest.MockedFunction; +const mockUseGenerateReportLink = useGenerateReportLink as jest.MockedFunction; +const mockUseTriggerReportGeneration = useTriggerReportGeneration as jest.MockedFunction; + +const mockReportsData = [ + { + dateGenerated: '2025-10-01T12:00:00Z', + reportType: 'Type A', + reportName: 'Test Report A', + downloadLink: 'https://example.com/report-a', + }, +]; + +describe('DataDownloadsPage', () => { + const mockMutate = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + mockUseGenerateReportLink.mockReturnValue({ + mutate: mockMutate, + } as any); + mockUseTriggerReportGeneration.mockReturnValue({ + mutate: mockMutate, + isPending: false, + } as any); + }); + + it('should render page with data', async () => { + mockUseGeneratedReports.mockReturnValue({ + data: mockReportsData, + isLoading: false, + } as any); + renderWithProviders(); + + expect(screen.getByText('Available Reports')).toBeInTheDocument(); + expect(screen.getByText(/The reports listed below are available for download/)).toBeInTheDocument(); + expect(screen.getByText(/To keep student data secure/)).toBeInTheDocument(); + }); + + it('should handle download report click', async () => { + const user = userEvent.setup(); + mockUseGeneratedReports.mockReturnValue({ + data: mockReportsData, + isLoading: false, + } as any); + + renderWithProviders(); + await user.click(screen.getByText('Download Report')); + expect(mockMutate).toHaveBeenCalledWith('https://example.com/report-a'); + }); +}); diff --git a/src/dataDownloads/DataDownloadsPage.tsx b/src/dataDownloads/DataDownloadsPage.tsx new file mode 100644 index 00000000..abfd5316 --- /dev/null +++ b/src/dataDownloads/DataDownloadsPage.tsx @@ -0,0 +1,53 @@ +import { Container } from '@openedx/paragon'; +import { messages } from './messages'; +import { useIntl } from '@openedx/frontend-base'; +import { DataDownloadTable } from './components/DataDownloadTable'; +import { useParams } from 'react-router-dom'; +import { useGeneratedReports, useGenerateReportLink } from './data/apiHook'; +import { useCallback } from 'react'; +import { ReportGenerationTabs } from './components/ReportGenerationTabs'; + +// TODO: remove once API is ready +const mockedData = [ + { + dateGenerated: '2025-10-01T12:00:00Z', + reportType: 'Type A', + reportName: 'Axim_ID101_2_student_state_from_block-v1_Axim+ID101+2+type@chapter+block@f9e8e1ec0d284c48a03cdc9d285563aa_2025-09-08-1934 (1)', + downloadLink: 'https://example.com/report-a', + }, + { + dateGenerated: '2025-10-01T12:00:00Z', + reportType: 'Type B', + reportName: 'Axim_ID101_2_student_state_from_block-v1_Axim+ID101+2+type@chapter+block@f9e8e1ec0d284c48a03cdc9d285563aa_2025-09-08-1934 (1)', + downloadLink: 'https://example.com/report-b', + }, +]; + +const DataDownloadsPage = () => { + const intl = useIntl(); + const { courseId } = useParams(); + const { data = mockedData, isLoading } = useGeneratedReports(courseId ?? ''); + const { mutate: generateReportLinkMutate } = useGenerateReportLink(courseId ?? ''); + + const handleDownload = useCallback((downloadLink: string) => { + generateReportLinkMutate(downloadLink); // TODO: pass the correct reportType + }, [generateReportLinkMutate]); + + return ( + +
+

{intl.formatMessage(messages.dataDownloadsTitle)}

+

{intl.formatMessage(messages.dataDownloadsDescription)}

+

{intl.formatMessage(messages.dataDownloadsReportExpirationPolicyMessage)}

+ +
+
+

{intl.formatMessage(messages.dataDownloadsGenerateReportTitle)}

+

{intl.formatMessage(messages.dataDownloadsGenerateReportDescription)}

+ +
+
+ ); +}; + +export { DataDownloadsPage }; diff --git a/src/dataDownloads/components/DataDownloadTable.test.tsx b/src/dataDownloads/components/DataDownloadTable.test.tsx new file mode 100644 index 00000000..eaf418b9 --- /dev/null +++ b/src/dataDownloads/components/DataDownloadTable.test.tsx @@ -0,0 +1,70 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { IntlProvider } from '@openedx/frontend-base'; +import { DataDownloadTable } from './DataDownloadTable'; +import { DownloadReportData } from '../types'; + +const mockData: DownloadReportData[] = [ + { + dateGenerated: '2025-10-01T12:00:00Z', + reportType: 'Type A', + reportName: 'Test Report A', + downloadLink: 'https://example.com/report-a.pdf', + }, + { + dateGenerated: '2025-10-02T12:00:00Z', + reportType: 'Type B', + reportName: 'Test Report B', + downloadLink: 'https://example.com/report-b.pdf', + }, +]; + +const renderComponent = (props) => { + return render( + + + + ); +}; + +describe('DataDownloadTable', () => { + const mockOnDownloadClick = jest.fn(); + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should render table with data and handle download click', async () => { + const user = userEvent.setup(); + renderComponent({ data: mockData, isLoading: false, onDownloadClick: mockOnDownloadClick }); + + expect(screen.getByText('Date Generated')).toBeInTheDocument(); + expect(screen.getByText('Report Type')).toBeInTheDocument(); + expect(screen.getByText('Report Name')).toBeInTheDocument(); + + expect(screen.getByText('2025-10-01T12:00:00Z')).toBeInTheDocument(); + expect(screen.getByText('Type A')).toBeInTheDocument(); + expect(screen.getByText('Test Report A')).toBeInTheDocument(); + + const downloadButtons = screen.getAllByText('Download Report'); + expect(downloadButtons).toHaveLength(2); + + await user.click(downloadButtons[0]); + expect(mockOnDownloadClick).toHaveBeenCalledWith('https://example.com/report-a.pdf'); + }); + + it('should render loading state', () => { + renderComponent({ data: [], isLoading: true, onDownloadClick: mockOnDownloadClick }); + + expect(screen.getByText('Date Generated')).toBeInTheDocument(); + expect(screen.getByText('Report Type')).toBeInTheDocument(); + expect(screen.getByText('Report Name')).toBeInTheDocument(); + }); + + it('should render empty table when no data provided', () => { + renderComponent({ data: [], isLoading: false, onDownloadClick: mockOnDownloadClick }); + + expect(screen.getByText('Date Generated')).toBeInTheDocument(); + expect(screen.getByText('Report Type')).toBeInTheDocument(); + expect(screen.getByText('Report Name')).toBeInTheDocument(); + }); +}); diff --git a/src/dataDownloads/components/DataDownloadTable.tsx b/src/dataDownloads/components/DataDownloadTable.tsx new file mode 100644 index 00000000..ec498d77 --- /dev/null +++ b/src/dataDownloads/components/DataDownloadTable.tsx @@ -0,0 +1,50 @@ +import { useIntl } from '@openedx/frontend-base'; +import { DataTable } from '@openedx/paragon'; +import { useCallback, useMemo } from 'react'; +import { messages } from '../messages'; +import { DownloadLinkCell } from './DownloadLinkCell'; +import { DownloadReportData } from '../types'; +import { ReportNameCell } from './ReportNameCell'; + +interface DataDownloadTableProps { + data: DownloadReportData[], + isLoading: boolean, + onDownloadClick: (downloadLink: string) => void, +} + +const DataDownloadTable = ({ data, isLoading, onDownloadClick }: DataDownloadTableProps) => { + const intl = useIntl(); + + const tableColumns = useMemo(() => [ + { accessor: 'dateGenerated', Header: intl.formatMessage(messages.dateGeneratedColumnName) }, + { accessor: 'reportType', Header: intl.formatMessage(messages.reportTypeColumnName) }, + ], [intl]); + + const DownloadCustomCell = useCallback(({ row }) => { + return ; + }, [onDownloadClick]); + + return ( + null} + > + + ); +}; + +export { DataDownloadTable }; diff --git a/src/dataDownloads/components/DownloadLinkCell.test.tsx b/src/dataDownloads/components/DownloadLinkCell.test.tsx new file mode 100644 index 00000000..f768c669 --- /dev/null +++ b/src/dataDownloads/components/DownloadLinkCell.test.tsx @@ -0,0 +1,69 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { DownloadLinkCell } from './DownloadLinkCell'; +import { IntlProvider } from '@openedx/frontend-base'; + +const mockOnDownloadClick = jest.fn(); + +const createMockRow = (downloadLink: string | undefined) => ({ + original: { + dateGenerated: '2025-10-01T12:00:00Z', + reportType: 'Type A', + reportName: 'Test Report', + downloadLink, + }, +}); + +const renderComponent = (props) => { + return render( + + + + ); +}; + +describe('DownloadLinkCell', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should render download button and handle click with valid download link', async () => { + const user = userEvent.setup(); + const downloadLink = 'https://example.com/report.pdf'; + const mockRow = createMockRow(downloadLink); + + renderComponent({ row: mockRow, onDownloadClick: mockOnDownloadClick }); + + const button = screen.getByRole('button', { name: 'Download Report' }); + expect(button).toBeInTheDocument(); + + await user.click(button); + expect(mockOnDownloadClick).toHaveBeenCalledWith(downloadLink); + }); + + it('should handle click with empty download link when downloadLink is undefined', async () => { + const user = userEvent.setup(); + const mockRow = createMockRow(undefined); + + renderComponent({ row: mockRow, onDownloadClick: mockOnDownloadClick }); + + const button = screen.getByRole('button', { name: 'Download Report' }); + await user.click(button); + + expect(mockOnDownloadClick).toHaveBeenCalledWith(''); + }); + + it('should handle click with empty download link when original is undefined', async () => { + const user = userEvent.setup(); + const mockRow = { original: undefined }; + + renderComponent({ row: mockRow, onDownloadClick: mockOnDownloadClick }); + + const button = screen.getByRole('button', { name: 'Download Report' }); + await user.click(button); + + expect(mockOnDownloadClick).toHaveBeenCalledWith(''); + }); +}); diff --git a/src/dataDownloads/components/DownloadLinkCell.tsx b/src/dataDownloads/components/DownloadLinkCell.tsx new file mode 100644 index 00000000..abc33b1a --- /dev/null +++ b/src/dataDownloads/components/DownloadLinkCell.tsx @@ -0,0 +1,21 @@ +import { useIntl } from '@openedx/frontend-base'; +import { Button } from '@openedx/paragon'; +import { messages } from '../messages'; +import { DataDownloadsCellProps } from '../types'; + +interface DownloadLinkCellProps extends DataDownloadsCellProps { + onDownloadClick: (downloadLink: string) => void, +} + +const DownloadLinkCell = ({ row, onDownloadClick }: DownloadLinkCellProps) => { + const intl = useIntl(); + const downloadLink = row.original?.downloadLink ?? ''; + + return ( + + ); +}; + +export { DownloadLinkCell }; diff --git a/src/dataDownloads/components/ReportGenerationTabs.test.tsx b/src/dataDownloads/components/ReportGenerationTabs.test.tsx new file mode 100644 index 00000000..0e96fcae --- /dev/null +++ b/src/dataDownloads/components/ReportGenerationTabs.test.tsx @@ -0,0 +1,116 @@ +import { screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import { ReportGenerationTabs } from './ReportGenerationTabs'; +import { useTriggerReportGeneration } from '../data/apiHook'; +import { renderWithProviders } from '../../testUtils'; + +// Mock ResizeObserver +global.ResizeObserver = jest.fn().mockImplementation(() => ({ + observe: jest.fn(), + unobserve: jest.fn(), + disconnect: jest.fn(), +})); + +// Mock the hooks +jest.mock('../data/apiHook'); +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useParams: () => ({ courseId: 'course-v1:Example+Course+2023' }), +})); + +// Mock the report tabs constant +jest.mock('../constants', () => ({ + REPORTS_TABS: [ + { + key: 'enrollment', + title: { id: 'enrollment.title', defaultMessage: 'Enrollment Reports' }, + reports: [ + { + reportKey: 'enrolled_students', + reportName: { id: 'enrollment.enrolled', defaultMessage: 'Enrolled Students' }, + reportDescription: { id: 'enrollment.enrolled.desc', defaultMessage: 'Generate enrolled students report' }, + buttonText: { id: 'enrollment.enrolled.button', defaultMessage: 'Generate Report' }, + }, + { + reportKey: 'unenrolled_students', + reportName: { id: 'enrollment.unenrolled', defaultMessage: 'Unenrolled Students' }, + reportDescription: { id: 'enrollment.unenrolled.desc', defaultMessage: 'Generate unenrolled students report' }, + buttonText: { id: 'enrollment.unenrolled.button', defaultMessage: 'Generate Report' }, + customAction: () => , + }, + ], + }, + { + key: 'grades', + title: { id: 'grades.title', defaultMessage: 'Grade Reports' }, + reports: [ + { + reportKey: 'grade_export', + reportName: { id: 'grades.export', defaultMessage: 'Grade Export' }, + reportDescription: { id: 'grades.export.desc', defaultMessage: 'Export all grades' }, + buttonText: { id: 'grades.export.button', defaultMessage: 'Export Grades' }, + }, + ], + }, + ], +})); + +const mockUseTriggerReportGeneration = useTriggerReportGeneration as jest.MockedFunction; + +describe('ReportGenerationTabs', () => { + let user: ReturnType; + const mockTriggerReportGeneration = jest.fn(); + + beforeEach(() => { + user = userEvent.setup(); + jest.clearAllMocks(); + + mockUseTriggerReportGeneration.mockReturnValue({ + mutate: mockTriggerReportGeneration, + isPending: false, + } as any); + }); + + it('should render all tabs with correct titles', () => { + renderWithProviders(); + + expect(screen.getByText('Enrollment Reports')).toBeInTheDocument(); + expect(screen.getByText('Grade Reports')).toBeInTheDocument(); + }); + + it('should render report cards in the first tab by default', () => { + renderWithProviders(); + + expect(screen.getByText('Enrolled Students')).toBeInTheDocument(); + expect(screen.getByText('Generate enrolled students report')).toBeInTheDocument(); + expect(screen.getByText('Unenrolled Students')).toBeInTheDocument(); + }); + + it('should switch between tabs and show different reports', async () => { + renderWithProviders(); + + // Initially in enrollment tab + expect(screen.getByText('Enrolled Students')).toBeInTheDocument(); + + // Click on grades tab + await user.click(screen.getByText('Grade Reports')); + + expect(screen.getByText('Grade Export')).toBeInTheDocument(); + expect(screen.getByText('Export all grades')).toBeInTheDocument(); + }); + + it('should disable buttons when reports are being generated', () => { + mockUseTriggerReportGeneration.mockReturnValue({ + mutate: mockTriggerReportGeneration, + isPending: true, + } as any); + + renderWithProviders(); + + const generateButtons = screen.getAllByRole('button', { name: 'Generate Report' }); + generateButtons.forEach(button => { + expect(button).toBeDisabled(); + }); + }); +}); diff --git a/src/dataDownloads/components/ReportGenerationTabs.tsx b/src/dataDownloads/components/ReportGenerationTabs.tsx new file mode 100644 index 00000000..123bc2f5 --- /dev/null +++ b/src/dataDownloads/components/ReportGenerationTabs.tsx @@ -0,0 +1,57 @@ +import { Tabs, Tab } from '@openedx/paragon'; +import { REPORTS_TABS } from '../constants'; +import { useIntl } from '@openedx/frontend-base'; +import { ActionCard } from '../../components/ActionCard'; +import { useTriggerReportGeneration } from '../data/apiHook'; +import { useParams } from 'react-router-dom'; +import { messages } from '../messages'; +import { useToastManager, ToastTypeEnum } from '../../providers/ToastManagerProvider'; + +export const ReportGenerationTabs = () => { + const { courseId = '' } = useParams(); + const intl = useIntl(); + const { mutate: triggerReportGeneration, isPending } = useTriggerReportGeneration(courseId); + const { showToast } = useToastManager(); + + const handleReportGeneration = (report) => { + triggerReportGeneration(report.reportKey, { + onSuccess: () => { + const message = intl.formatMessage(messages.reportGenerationSuccessMessage, { reportName: report.reportName }); + showToast({ + message, + type: ToastTypeEnum.SUCCESS, + }); + }, + onError: (error) => { + console.log('Error generating report:', error); + showToast({ + message: intl.formatMessage(messages.reportGenerationErrorMessage), + type: ToastTypeEnum.ERROR, + }); + }, + }); + }; + + return ( + + {REPORTS_TABS.map((tab) => ( + + { tab.reports.map((report) => ( + handleReportGeneration(report)} + customAction={report.customAction ? : undefined} + isLoading={isPending} + /> + )) } + + ))} + + ); +}; diff --git a/src/dataDownloads/components/ReportNameCell.test.tsx b/src/dataDownloads/components/ReportNameCell.test.tsx new file mode 100644 index 00000000..d03f6305 --- /dev/null +++ b/src/dataDownloads/components/ReportNameCell.test.tsx @@ -0,0 +1,53 @@ +import { render, screen } from '@testing-library/react'; +import { ReportNameCell } from './ReportNameCell'; +import { IntlProvider } from '@openedx/frontend-base'; + +const createMockRow = (reportName: string | undefined = 'Test Report Name') => ({ + original: { + dateGenerated: '2025-10-01T12:00:00Z', + reportType: 'Type A', + reportName, + downloadLink: 'https://example.com/report.pdf', + }, +}); + +const renderComponent = (props) => { + return render( + + + + ); +}; + +describe('ReportNameCell', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should render with short name', async () => { + const reportName = 'Test Report Name'; + const mockRow = createMockRow(reportName); + + renderComponent({ row: mockRow }); + const nameElement = screen.getByText(reportName); + + expect(nameElement).toBeInTheDocument(); + expect(nameElement).toHaveTextContent(reportName); + expect(nameElement).toHaveAttribute('title', reportName); + }); + + it('should render with long report name and show full name in title attribute', async () => { + const longReportName = 'Very Long Report Name That Should Be Truncated With Ellipsis Because It Exceeds The Maximum Width Of The Container Element And Should Show Full Text In Title'; + const mockRow = createMockRow(longReportName); + + renderComponent({ row: mockRow }); + + const nameElement = screen.getByText(longReportName); + expect(nameElement).toBeInTheDocument(); + expect(nameElement).toHaveTextContent(longReportName); + expect(nameElement).toHaveAttribute('title', longReportName); + expect(nameElement).toHaveClass('text-truncate'); + }); +}); diff --git a/src/dataDownloads/components/ReportNameCell.tsx b/src/dataDownloads/components/ReportNameCell.tsx new file mode 100644 index 00000000..c7c1ce59 --- /dev/null +++ b/src/dataDownloads/components/ReportNameCell.tsx @@ -0,0 +1,14 @@ +import { DataDownloadsCellProps } from '../types'; + +const ReportNameCell = ({ row }: DataDownloadsCellProps) => { + return ( +
+ {row.original.reportName} +
+ ); +}; + +export { ReportNameCell }; diff --git a/src/dataDownloads/components/SectionSelectorAction.tsx b/src/dataDownloads/components/SectionSelectorAction.tsx new file mode 100644 index 00000000..ff49f789 --- /dev/null +++ b/src/dataDownloads/components/SectionSelectorAction.tsx @@ -0,0 +1,38 @@ +import { Button, Card, Form, FormLabel, Icon, OverlayTrigger, Tooltip } from '@openedx/paragon'; +import { InfoOutline } from '@openedx/paragon/icons'; +import { messages } from '../messages'; +import { useIntl } from '@openedx/frontend-base'; + +const SectionSelectorAction = () => { + const intl = useIntl(); + return ( + + +
+
+ {intl.formatMessage(messages.sectionOrProblemLabel)} + + {intl.formatMessage(messages.sectionOrProblemExampleTooltipText)} + + )} + > + + +
+
+ + +
+
+ +
+ +
+ ); +}; + +export { SectionSelectorAction }; diff --git a/src/dataDownloads/constants.ts b/src/dataDownloads/constants.ts new file mode 100644 index 00000000..7f1ca62d --- /dev/null +++ b/src/dataDownloads/constants.ts @@ -0,0 +1,107 @@ +import { SectionSelectorAction } from './components/SectionSelectorAction'; +import { messages } from './messages'; + +export const REPORTS_TABS = [ + { + key: 'enrollment', + title: messages.enrollmentReportsTabTitle, + reports: [ + { + reportKey: 'enrolledStudents', + reportType: '', + reportName: messages.enrollmentReportsTabEnrolledStudentsReportName, + reportDescription: messages.enrollmentReportsTabEnrolledStudentsReportDescription, + buttonText: messages.enrollmentReportsTabEnrolledStudentsReportButtonText, + }, + { + reportKey: 'pendingEnrollments', + reportType: '', + reportName: messages.enrollmentReportsTabPendingEnrollmentsReportName, + reportDescription: messages.enrollmentReportsTabPendingEnrollmentsReportDescription, + buttonText: messages.enrollmentReportsTabPendingEnrollmentsReportButtonText, + }, + { + reportKey: 'pendingActivations', + reportType: '', + reportName: messages.enrollmentReportsTabPendingActivationsReportName, + reportDescription: messages.enrollmentReportsTabPendingActivationsReportDescription, + buttonText: messages.enrollmentReportsTabPendingActivationsReportButtonText, + }, + { + reportKey: 'anonymizedStudentsIds', + reportType: '', + reportName: messages.enrollmentReportsTabAnonymizedStudentsIdsReportName, + reportDescription: messages.enrollmentReportsTabAnonymizedStudentsIdsReportDescription, + buttonText: messages.enrollmentReportsTabAnonymizedStudentsIdsReportButtonText, + } + ], + }, + { + key: 'grading', + title: messages.gradingReportsTabTitle, + reports: [ + { + reportKey: 'grade', + reportType: '', + reportName: messages.gradingReportsTabGradeReportName, + reportDescription: messages.gradingReportsTabGradeReportDescription, + buttonText: messages.gradingReportsTabGradeReportButtonText, + }, + { + reportKey: 'problemGrade', + reportType: '', + reportName: messages.gradingReportsTabProblemGradeReportName, + reportDescription: messages.gradingReportsTabProblemGradeReportDescription, + buttonText: messages.gradingReportsTabProblemGradeReportButtonText, + }, + ] + }, + { + key: 'problemResponse', + title: messages.problemResponseReportsTabTitle, + reports: [ + { + reportKey: 'oraSummary', + reportType: '', + reportName: messages.problemResponseReportsTabORASummaryReportName, + reportDescription: messages.problemResponseReportsTabORASummaryReportDescription, + buttonText: messages.problemResponseReportsTabORASummaryReportButtonText, + }, + { + reportKey: 'oraData', + reportType: '', + reportName: messages.problemResponseReportsTabORADataReportName, + reportDescription: messages.problemResponseReportsTabORADataReportDescription, + buttonText: messages.problemResponseReportsTabORADataReportButtonText, + }, + { + reportKey: 'submissionFilesArchive', + reportType: '', + reportName: messages.problemResponseReportsTabSubmissionFilesArchiveName, + reportDescription: messages.problemResponseReportsTabSubmissionFilesArchiveDescription, + buttonText: messages.problemResponseReportsTabSubmissionFilesArchiveButtonText, + }, + { + reportKey: 'problemResponse', + reportType: '', + reportName: messages.problemResponseReportsTabProblemResponseReportName, + reportDescription: messages.problemResponseReportsTabProblemResponseReportDescription, + buttonText: messages.problemResponseReportsTabProblemResponseReportButtonText, + customAction: SectionSelectorAction, + }, + ] + }, + { + key: 'certificate', + title: messages.certificateReportsTabTitle, + reports: [ + { + reportKey: 'issuedCertificates', + reportType: '', + reportName: messages.certificateReportsTabIssuedCertificatesReportName, + reportDescription: messages.certificateReportsTabIssuedCertificatesReportDescription, + buttonText: messages.certificateReportsTabIssuedCertificatesReportButtonText, + }, + ] + } +]; diff --git a/src/dataDownloads/data/api.test.ts b/src/dataDownloads/data/api.test.ts new file mode 100644 index 00000000..3fc57522 --- /dev/null +++ b/src/dataDownloads/data/api.test.ts @@ -0,0 +1,42 @@ +import { getGeneratedReports, generateReportLink } from './api'; +import { camelCaseObject, getAuthenticatedHttpClient } from '@openedx/frontend-base'; +import { getApiBaseUrl } from '../../data/api'; + +jest.mock('@openedx/frontend-base'); +jest.mock('../../data/api'); + +const mockGet = jest.fn(); +const mockPost = jest.fn(); +const mockGetAuthenticatedHttpClient = getAuthenticatedHttpClient as jest.MockedFunction; +const mockGetApiBaseUrl = getApiBaseUrl as jest.MockedFunction; +const mockCamelCaseObject = camelCaseObject as jest.MockedFunction; + +describe('dataDownloads api', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockGetAuthenticatedHttpClient.mockReturnValue({ + get: mockGet, + post: mockPost, + } as any); + mockGetApiBaseUrl.mockReturnValue('http://localhost:8000'); + mockCamelCaseObject.mockImplementation((data) => data); + }); + + it('should handle getGeneratedReports API call', async () => { + const mockReportsData = { reports: ['report1', 'report2'] }; + mockGet.mockResolvedValue({ data: mockReportsData }); + const reportsResult = await getGeneratedReports('course-123'); + expect(mockGet).toHaveBeenCalledWith('http://localhost:8000/api/instructor/v1/courses/course-123'); + expect(mockCamelCaseObject).toHaveBeenCalledWith(mockReportsData); + expect(reportsResult).toEqual(mockReportsData); + }); + + it('should handle generateReportLink API calls', async () => { + const mockGenerateData = { success: true }; + mockPost.mockResolvedValue({ data: mockGenerateData }); + const generateResult = await generateReportLink('course-456', 'type-a'); + expect(mockPost).toHaveBeenCalledWith('http://localhost:8000/api/instructor/v1/courses/course-456/reports/type-a/generate/'); + expect(mockCamelCaseObject).toHaveBeenCalledWith(mockGenerateData); + expect(generateResult).toEqual(mockGenerateData); + }); +}); diff --git a/src/dataDownloads/data/api.ts b/src/dataDownloads/data/api.ts new file mode 100644 index 00000000..a241e288 --- /dev/null +++ b/src/dataDownloads/data/api.ts @@ -0,0 +1,23 @@ +import { camelCaseObject, getAuthenticatedHttpClient } from '@openedx/frontend-base'; +import { getApiBaseUrl } from '../../data/api'; + +export const getGeneratedReports = async (courseId) => { + const { data } = await getAuthenticatedHttpClient() + // TODO: Validate if url is correct once the new API endpoint is available + .get(`${getApiBaseUrl()}/api/instructor/v1/courses/${courseId}`); + return camelCaseObject(data); +}; + +export const generateReportLink = async (courseId, reportType) => { + const { data } = await getAuthenticatedHttpClient() + // TODO: Validate if url is correct once the new API endpoint is available + .post(`${getApiBaseUrl()}/api/instructor/v1/courses/${courseId}/reports/${reportType}/generate/`); + return camelCaseObject(data); +}; + +export const triggerReportGeneration = async (courseId, reportType) => { + const { data } = await getAuthenticatedHttpClient() + // TODO: Validate if url is correct once the new API endpoint is available + .post(`${getApiBaseUrl()}/api/instructor/v1/courses/${courseId}/reports/${reportType}/generate/`); + return camelCaseObject(data); +}; diff --git a/src/dataDownloads/data/apiHook.test.tsx b/src/dataDownloads/data/apiHook.test.tsx new file mode 100644 index 00000000..81a1ccd9 --- /dev/null +++ b/src/dataDownloads/data/apiHook.test.tsx @@ -0,0 +1,108 @@ +import { renderHook, waitFor } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { useGeneratedReports, useGenerateReportLink, queryKeys } from './apiHook'; +import { generateReportLink, getGeneratedReports } from './api'; +import { ReactNode } from 'react'; + +jest.mock('./api'); + +const mockGetGeneratedReports = getGeneratedReports as jest.MockedFunction; +const mockGenerateReportLink = generateReportLink as jest.MockedFunction; + +const createWrapper = () => { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false }, + }, + }); + const WrapedComponent = ({ children }: { children: ReactNode }) => ( + {children} + ); + return WrapedComponent; +}; + +describe('dataDownloads apiHook', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('queryKeys', () => { + it('should generate correct query keys', () => { + expect(queryKeys.generatedReports('course-123')).toEqual(['generated-reports', 'course-123']); + expect(queryKeys.generateReportLink('course-456')).toEqual(['report-link', 'course-456']); + }); + }); + + describe('useGeneratedReports', () => { + it('should fetch generated reports successfully', async () => { + const mockData = { reports: ['report1', 'report2'] }; + mockGetGeneratedReports.mockResolvedValue(mockData); + + const { result } = renderHook( + () => useGeneratedReports('course-123'), + { wrapper: createWrapper() } + ); + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true); + }); + + expect(mockGetGeneratedReports).toHaveBeenCalledWith('course-123'); + expect(result.current.data).toEqual(mockData); + }); + + it('should handle fetch error', async () => { + mockGetGeneratedReports.mockRejectedValue(new Error('API Error')); + + const { result } = renderHook( + () => useGeneratedReports('course-123'), + { wrapper: createWrapper() } + ); + + await waitFor(() => { + expect(result.current.isError).toBe(true); + }); + + expect(result.current.error).toEqual(new Error('API Error')); + }); + }); + + describe('useGenerateReportLink', () => { + it('should generate report link successfully', async () => { + const mockResponse = { downloadUrl: 'http://example.com/report' }; + mockGenerateReportLink.mockResolvedValue(mockResponse); + + const { result } = renderHook( + () => useGenerateReportLink('course-123'), + { wrapper: createWrapper() } + ); + + result.current.mutate('report-type-a'); + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true); + }); + + expect(mockGenerateReportLink).toHaveBeenCalledWith('course-123', 'report-type-a'); + expect(result.current.data).toEqual(mockResponse); + }); + + it('should handle mutation error', async () => { + mockGenerateReportLink.mockRejectedValue(new Error('Generation failed')); + + const { result } = renderHook( + () => useGenerateReportLink('course-123'), + { wrapper: createWrapper() } + ); + + result.current.mutate('report-type-a'); + + await waitFor(() => { + expect(result.current.isError).toBe(true); + }); + + expect(result.current.error).toEqual(new Error('Generation failed')); + }); + }); +}); diff --git a/src/dataDownloads/data/apiHook.ts b/src/dataDownloads/data/apiHook.ts new file mode 100644 index 00000000..c07bf203 --- /dev/null +++ b/src/dataDownloads/data/apiHook.ts @@ -0,0 +1,29 @@ +import { useMutation, useQuery } from '@tanstack/react-query'; +import { generateReportLink, getGeneratedReports, triggerReportGeneration } from './api'; + +export const queryKeys = { + generatedReports: (courseId: string) => ['generated-reports', courseId], + generateReportLink: (courseId: string) => ['report-link', courseId], + triggerReportGeneration: (courseId: string) => ['trigger-report-generation', courseId], +}; + +export const useGeneratedReports = (courseId: string) => ( + useQuery({ + queryKey: queryKeys.generatedReports(courseId), + queryFn: () => getGeneratedReports(courseId), + }) +); + +export const useGenerateReportLink = (courseId: string) => ( + useMutation({ + mutationKey: queryKeys.generateReportLink(courseId), + mutationFn: (reportType: string) => generateReportLink(courseId, reportType), + }) +); + +export const useTriggerReportGeneration = (courseId: string) => ( + useMutation({ + mutationKey: queryKeys.triggerReportGeneration(courseId), + mutationFn: (reportType: string) => triggerReportGeneration(courseId, reportType), + }) +); diff --git a/src/dataDownloads/messages.ts b/src/dataDownloads/messages.ts new file mode 100644 index 00000000..ccb6d9f1 --- /dev/null +++ b/src/dataDownloads/messages.ts @@ -0,0 +1,257 @@ +import { defineMessages } from '@openedx/frontend-base'; + +const messages = defineMessages({ + dataDownloadsTitle: { + id: 'instruct.dataDownloads.page.title', + defaultMessage: 'Available Reports', + description: 'Title for data downloads page', + }, + dataDownloadsDescription: { + id: 'instruct.dataDownloads.page.description', + defaultMessage: 'The reports listed below are available for download, identified by UTC date and time of generation.', + description: 'Description for data downloads page', + }, + dataDownloadsReportExpirationPolicyMessage: { + id: 'instruct.dataDownloads.page.reportExpiration', + defaultMessage: 'To keep student data secure, you cannot save or email these links for direct access. Copies of links expire within 5 minutes. Report files are deleted 90 days after generation. If you will need access to old reports, download and store the files, in accordance with your institution\'s data security policies.', + description: 'Expiration policy message for data downloads page', + }, + dateGeneratedColumnName: { + id: 'instruct.dataDownloads.table.column.dateGenerated', + defaultMessage: 'Date Generated', + description: 'Column name for date generated in data downloads table', + }, + reportTypeColumnName: { + id: 'instruct.dataDownloads.table.column.reportType', + defaultMessage: 'Report Type', + description: 'Column name for report type in data downloads table', + }, + reportNameColumnName: { + id: 'instruct.dataDownloads.table.column.reportName', + defaultMessage: 'Report Name', + description: 'Column name for report name in data downloads table', + }, + downloadLinkLabel: { + id: 'instruct.dataDownloads.table.downloadLink', + defaultMessage: 'Download Report', + description: 'Label for download link in the download column of data downloads table', + }, + dataDownloadsGenerateReportTitle: { + id: 'instruct.dataDownloads.page.generate.reports.title', + defaultMessage: 'Generate Reports', + description: 'Title for generate reports section', + }, + dataDownloadsGenerateReportDescription: { + id: 'instruct.dataDownloads.page.generate.reports.description', + defaultMessage: 'Once generated, these CSV file reports will be available in the Available Reports section. Generation takes longer for larger courses.', + description: 'Description for generate reports section', + }, + enrollmentReportsTabTitle: { + id: 'instruct.dataDownloads.page.generate.reports.enrollment.tab.title', + defaultMessage: 'Enrollment Reports', + description: 'Title for enrollment reports tab', + }, + enrollmentReportsTabEnrolledStudentsReportName: { + id: 'instruct.dataDownloads.page.generate.reports.enrollment.tab.enrolledStudents.reportName', + defaultMessage: 'Enrolled Students Reports', + description: 'Report name for enrolled students report in enrollment reports tab', + }, + enrollmentReportsTabEnrolledStudentsReportDescription: { + id: 'instruct.dataDownloads.page.generate.reports.enrollment.tab.enrolledStudents.reportDescription', + defaultMessage: 'Report of all enrolled learners for this course including email address and username.', + description: 'Report description for enrolled students report in enrollment reports tab', + }, + enrollmentReportsTabEnrolledStudentsReportButtonText: { + id: 'instruct.dataDownloads.page.generate.reports.enrollment.tab.enrolledStudents.reportButtonText', + defaultMessage: 'Generate Enrolled Students Report', + description: 'Button text for enrolled students report generation in enrollment reports tab', + }, + enrollmentReportsTabPendingEnrollmentsReportName: { + id: 'instruct.dataDownloads.page.generate.reports.enrollment.tab.pendingEnrollments.reportName', + defaultMessage: 'Pending Enrollments Report', + description: 'Report name for pending enrollments report in enrollment reports tab', + }, + enrollmentReportsTabPendingEnrollmentsReportDescription: { + id: 'instruct.dataDownloads.page.generate.reports.enrollment.tab.pendingEnrollments.reportDescription', + defaultMessage: 'Report of learners who can enroll in the course but have not done so yet.', + description: 'Report description for pending enrollments report in enrollment reports tab', + }, + enrollmentReportsTabPendingEnrollmentsReportButtonText: { + id: 'instruct.dataDownloads.page.generate.reports.enrollment.tab.pendingEnrollments.reportButtonText', + defaultMessage: 'Generate Pending Enrollments Report', + description: 'Button text for pending enrollments report generation in enrollment reports tab', + }, + enrollmentReportsTabPendingActivationsReportName: { + id: 'instruct.dataDownloads.page.generate.reports.enrollment.tab.pendingActivations.reportName', + defaultMessage: 'Pending Activations Report', + description: 'Report name for pending activations report in enrollment reports tab', + }, + enrollmentReportsTabPendingActivationsReportDescription: { + id: 'instruct.dataDownloads.page.generate.reports.enrollment.tab.pendingActivations.reportDescription', + defaultMessage: 'Report of enrolled learners who have not yet activated their account.', + description: 'Report description for pending activations report in enrollment reports tab', + }, + enrollmentReportsTabPendingActivationsReportButtonText: { + id: 'instruct.dataDownloads.page.generate.reports.enrollment.tab.pendingActivations.reportButtonText', + defaultMessage: 'Generate Pending Activations Report', + description: 'Button text for pending activations report generation in enrollment reports tab', + }, + enrollmentReportsTabAnonymizedStudentsIdsReportName: { + id: 'instruct.dataDownloads.page.generate.reports.enrollment.tab.anonymizedStudentsIds.reportName', + defaultMessage: 'Anonymized Students IDs Report', + description: 'Report name for anonymized students IDs report in enrollment reports tab', + }, + enrollmentReportsTabAnonymizedStudentsIdsReportDescription: { + id: 'instruct.dataDownloads.page.generate.reports.enrollment.tab.anonymizedStudentsIds.reportDescription', + defaultMessage: 'Report of enrolled learners who have not yet activated their account.', + description: 'Report description for anonymized students IDs report in enrollment reports tab', + }, + enrollmentReportsTabAnonymizedStudentsIdsReportButtonText: { + id: 'instruct.dataDownloads.page.generate.reports.enrollment.tab.anonymizedStudentsIds.reportButtonText', + defaultMessage: 'Generate Anonymized Students IDs Report', + description: 'Button text for anonymized students IDs report generation in enrollment reports tab', + }, + gradingReportsTabTitle: { + id: 'instruct.dataDownloads.page.generate.reports.grading.tab.title', + defaultMessage: 'Grading Reports', + description: 'Title for grading reports tab', + }, + gradingReportsTabGradeReportName: { + id: 'instruct.dataDownloads.page.generate.reports.grading.tab.grade.reportName', + defaultMessage: 'Grade Report', + description: 'Report name for grade report in grading reports tab', + }, + gradingReportsTabGradeReportDescription: { + id: 'instruct.dataDownloads.page.generate.reports.grading.tab.grade.reportDescription', + defaultMessage: 'Report of all grades for enrolled students.', + description: 'Report description for grade report in grading reports tab', + }, + gradingReportsTabGradeReportButtonText: { + id: 'instruct.dataDownloads.page.generate.reports.grading.tab.grade.reportButtonText', + defaultMessage: 'Generate Grade Report', + description: 'Button text for grade report generation in grading reports tab', + }, + gradingReportsTabProblemGradeReportName: { + id: 'instruct.dataDownloads.page.generate.reports.grading.tab.problemGrade.reportName', + defaultMessage: 'Problem Grade Report', + description: 'Report name for problem grade report in grading reports tab', + }, + gradingReportsTabProblemGradeReportDescription: { + id: 'instruct.dataDownloads.page.generate.reports.grading.tab.problemGrade.reportDescription', + defaultMessage: 'Report of all problem grades for enrolled students.', + description: 'Report description for problem grade report in grading reports tab', + }, + gradingReportsTabProblemGradeReportButtonText: { + id: 'instruct.dataDownloads.page.generate.reports.grading.tab.problemGrade.reportButtonText', + defaultMessage: 'Generate Problem Grade Report', + description: 'Button text for problem grade report generation in grading reports tab', + }, + problemResponseReportsTabTitle: { + id: 'instruct.dataDownloads.page.generate.reports.problemResponse.tab.title', + defaultMessage: 'Problem Response Reports', + description: 'Title for problem response reports tab', + }, + problemResponseReportsTabORASummaryReportName: { + id: 'instruct.dataDownloads.page.generate.reports.problemResponse.tab.oraSummary.reportName', + defaultMessage: 'ORA Summary Report', + description: 'Report name for ORA summary report in problem response reports tab', + }, + problemResponseReportsTabORASummaryReportDescription: { + id: 'instruct.dataDownloads.page.generate.reports.problemResponse.tab.oraSummary.reportDescription', + defaultMessage: 'Report of details and statuses for ORA.', + description: 'Report description for ORA summary report in problem response reports tab', + }, + problemResponseReportsTabORASummaryReportButtonText: { + id: 'instruct.dataDownloads.page.generate.reports.problemResponse.tab.oraSummary.reportButtonText', + defaultMessage: 'Generate ORA Summary Report', + description: 'Button text for ORA summary report generation in problem response reports tab', + }, + problemResponseReportsTabORADataReportName: { + id: 'instruct.dataDownloads.page.generate.reports.problemResponse.tab.oraData.reportName', + defaultMessage: 'ORA Data Report', + description: 'Report name for ORA data report in problem response reports tab', + }, + problemResponseReportsTabORADataReportDescription: { + id: 'instruct.dataDownloads.page.generate.reports.problemResponse.tab.oraData.reportDescription', + defaultMessage: 'Report of all ORA assignment details.', + description: 'Report description for ORA data report in problem response reports tab', + }, + problemResponseReportsTabORADataReportButtonText: { + id: 'instruct.dataDownloads.page.generate.reports.problemResponse.tab.oraData.reportButtonText', + defaultMessage: 'Generate ORA Data Report', + description: 'Button text for ORA data report generation in problem response reports tab', + }, + problemResponseReportsTabSubmissionFilesArchiveName: { + id: 'instruct.dataDownloads.page.generate.reports.problemResponse.tab.submissionFilesArchive.reportName', + defaultMessage: 'Submission Files Archive', + description: 'Report name for submission files archive in problem response reports tab', + }, + problemResponseReportsTabSubmissionFilesArchiveDescription: { + id: 'instruct.dataDownloads.page.generate.reports.problemResponse.tab.submissionFilesArchive.reportDescription', + defaultMessage: 'ZIP file containing all submission texts and attachments.', + description: 'Report description for submission files archive in problem response reports tab', + }, + problemResponseReportsTabSubmissionFilesArchiveButtonText: { + id: 'instruct.dataDownloads.page.generate.reports.problemResponse.tab.submissionFilesArchive.reportButtonText', + defaultMessage: 'Generate Submission Files Archive', + description: 'Button text for submission files archive generation in problem response reports tab', + }, + problemResponseReportsTabProblemResponseReportName: { + id: 'instruct.dataDownloads.page.generate.reports.problemResponse.tab.problemResponse.reportName', + defaultMessage: 'Problem Response Report', + description: 'Report name for problem response report in problem response reports tab', + }, + problemResponseReportsTabProblemResponseReportDescription: { + id: 'instruct.dataDownloads.page.generate.reports.problemResponse.tab.problemResponse.reportDescription', + defaultMessage: 'Report of all students answears to a problem. NOTE: You also select a section or a chapter to include results of all problems in that section or chapter.', + description: 'Report description for problem response report in problem response reports tab', + }, + problemResponseReportsTabProblemResponseReportButtonText: { + id: 'instruct.dataDownloads.page.generate.reports.problemResponse.tab.problemResponse.reportButtonText', + defaultMessage: 'Generate Problem Report', + description: 'Button text for problem response report generation in problem response reports tab', + }, + certificateReportsTabTitle: { + id: 'instruct.dataDownloads.page.generate.reports.certificate.tab.title', + defaultMessage: 'Certificate Reports', + description: 'Title for certificate reports tab', + }, + certificateReportsTabIssuedCertificatesReportName: { + id: 'instruct.dataDownloads.page.generate.reports.certificate.tab.issuedCertificates.reportName', + defaultMessage: 'Issued Certificates', + description: 'Report name for issued certificates report in certificate reports tab', + }, + certificateReportsTabIssuedCertificatesReportDescription: { + id: 'instruct.dataDownloads.page.generate.reports.certificate.tab.issuedCertificates.reportDescription', + defaultMessage: 'Report of Certificates Issued.', + description: 'Report description for issued certificates report in certificate reports tab', + }, + certificateReportsTabIssuedCertificatesReportButtonText: { + id: 'instruct.dataDownloads.page.generate.reports.certificate.tab.issuedCertificates.reportButtonText', + defaultMessage: 'Generate Certificates Report', + description: 'Button text for issued certificates report generation in certificate reports tab', + }, + + sectionOrProblemLabel: { + id: 'instruct.dataDownloads.page.generate.reports.problemResponse.tab.problemResponse.sectionOrProblemLabel', + defaultMessage: 'Specify Section or Problem: ', + description: 'Label for section or problem input in problem response reports tab', + }, + sectionOrProblemExampleTooltipText: { + id: 'instruct.dataDownloads.page.generate.reports.problemResponse.tab.problemResponse.sectionOrProblemExampleTooltipText', + defaultMessage: 'Example: block-v1:edX+DemoX+2015+type@problem+block@618c5933b8b544e4a4cc103d3e508378', + description: 'Example text for section or problem input in problem response reports tab', + }, + reportGenerationSuccessMessage: { + id: 'instruct.dataDownloads.page.generate.reports.reportGenerationSuccessMessage', + defaultMessage: 'The {reportName} report is being created. To view the status of the report, see Pending Tasks', + description: 'Success message shown when report generation is successfully triggered', + }, + reportGenerationErrorMessage: { + id: 'instruct.dataDownloads.page.generate.reports.reportGenerationErrorMessage', + defaultMessage: 'There was an error generating the report. Please try again later.', + description: 'Error message shown when report generation fails', + }, +}); + +export { messages }; diff --git a/src/dataDownloads/types.ts b/src/dataDownloads/types.ts new file mode 100644 index 00000000..cef508d6 --- /dev/null +++ b/src/dataDownloads/types.ts @@ -0,0 +1,10 @@ +import { TableCellValue } from '../types'; + +export interface DownloadReportData { + dateGenerated: string, + reportType: string, + reportName: string, + downloadLink: string, +} + +export type DataDownloadsCellProps = TableCellValue; diff --git a/src/main.scss b/src/main.scss index 333ca14e..c657aee8 100644 --- a/src/main.scss +++ b/src/main.scss @@ -1,4 +1,25 @@ -@import "@edx/brand/paragon/fonts"; -@import "@edx/brand/paragon/variables"; -@import "@openedx/paragon/scss/core/core"; -@import "@edx/brand/paragon/overrides"; +@use "@edx/brand/paragon/fonts"; +@use "@edx/brand/paragon/variables"; +@use "@openedx/paragon/scss/core/core" as paragon; +@use "@edx/brand/paragon/overrides"; + +.flex-1 { + flex: 1; +} + +.flex-2 { + flex: 2; +} + +.action-card { + border-radius: 0%; + box-shadow: none; + border-bottom: paragon.$gray-500 solid 1px; + background-color: paragon.$light-200; +} + +.toast-container { + // Move toast to the right + left: auto; + right: 1.25rem; +} \ No newline at end of file diff --git a/src/providers/ToastManagerProvider.test.tsx b/src/providers/ToastManagerProvider.test.tsx new file mode 100644 index 00000000..371088ba --- /dev/null +++ b/src/providers/ToastManagerProvider.test.tsx @@ -0,0 +1,129 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { IntlProvider } from '@openedx/frontend-base'; +import { ToastManagerProvider, useToastManager, ToastTypeEnum } from './ToastManagerProvider'; + +// Test component to trigger toast actions +const TestComponent = () => { + const { showToast } = useToastManager(); + + return ( +
+ + + + + +
+ ); +}; + +const renderWithProvider = (component: React.ReactElement) => { + return render( + + + {component} + + + ); +}; + +describe('ToastManagerProvider', () => { + let user: ReturnType; + + beforeEach(() => { + jest.clearAllTimers(); + jest.useFakeTimers(); + user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime }); + }); + + afterEach(() => { + jest.runOnlyPendingTimers(); + jest.useRealTimers(); + }); + + it('should render children correctly', () => { + renderWithProvider(
Test Content
); + + expect(screen.getByText('Test Content')).toBeInTheDocument(); + }); + + it('should throw error when useToastManager is used outside provider', () => { + const consoleError = jest.spyOn(console, 'error').mockImplementation(() => {}); + + const InvalidComponent = () => { + useToastManager(); + return
Invalid
; + }; + + expect(() => render()).toThrow( + 'useToastManager must be used within a ToastManagerProvider' + ); + + consoleError.mockRestore(); + }); + + it('should display success toast when showToast is called', async () => { + renderWithProvider(); + + await user.click(screen.getByText('Show Success Toast')); + + expect(screen.getByText('Success message')).toBeInTheDocument(); + }); + + it('should display error toast with retry button', async () => { + renderWithProvider(); + + await user.click(screen.getByText('Show Error Retry Toast')); + + expect(screen.getByText('Error with retry')).toBeInTheDocument(); + expect(screen.getByText('Retry')).toBeInTheDocument(); + }); + + it('should remove toast after timeout', async () => { + renderWithProvider(); + + await user.click(screen.getByText('Show Success Toast')); + + expect(screen.getByText('Success message')).toBeInTheDocument(); + + // Fast-forward timers to trigger toast removal + jest.advanceTimersByTime(5000); + + await waitFor(() => { + expect(screen.queryByText('Success message')).not.toBeInTheDocument(); + }); + }); + + it('should handle multiple toasts simultaneously', async () => { + renderWithProvider(); + + await user.click(screen.getByText('Show Success Toast')); + await user.click(screen.getByText('Show Error Toast')); + + expect(screen.getByText('Success message')).toBeInTheDocument(); + expect(screen.getByText('Error message')).toBeInTheDocument(); + }); +}); diff --git a/src/providers/ToastManagerProvider.tsx b/src/providers/ToastManagerProvider.tsx new file mode 100644 index 00000000..af620617 --- /dev/null +++ b/src/providers/ToastManagerProvider.tsx @@ -0,0 +1,92 @@ +import { + createContext, useContext, useState, useMemo, +} from 'react'; +import { Toast } from '@openedx/paragon'; +import { messages } from './messages'; +import { useIntl } from '@openedx/frontend-base'; +import { DEFAULT_TOAST_DELAY } from '../constants'; + +export const ToastTypeEnum = { + SUCCESS: 'success', + ERROR: 'error', + ERROR_RETRY: 'error-retry', +} as const; + +export type ToastType = typeof ToastTypeEnum[keyof typeof ToastTypeEnum]; + +export interface AppToast { + id: string, + message: string, + type: ToastType, + onRetry?: () => void, + delay?: number, +} + +interface ToastManagerContextType { + showToast: (toast: Omit) => void, +} + +interface ToastManagerProviderProps { + children: React.ReactNode | React.ReactNode[], +} + +const ToastManagerContext = createContext(undefined); + +export const ToastManagerProvider = ({ children }: ToastManagerProviderProps) => { + const intl = useIntl(); + const [toasts, setToasts] = useState<(AppToast & { visible: boolean })[]>([]); + + const showToast = (toast: Omit) => { + const id = `toast-notification-${Date.now()}-${Math.floor(Math.random() * 1000000)}`; + const newToast = { ...toast, id, visible: true }; + setToasts(prev => [...prev, newToast]); + }; + + const discardToast = (id: string) => { + setToasts(prev => prev.map(t => (t.id === id ? { ...t, visible: false } : t))); + + setTimeout(() => { + setToasts(prev => prev.filter(t => t.id !== id)); + }, 5000); + }; + + const value = useMemo(() => { + return ({ + showToast + }); + }, []); + + return ( + + {children} + +
+ {toasts.map(toast => ( + discardToast(toast.id)} + delay={toast.delay ?? DEFAULT_TOAST_DELAY} + action={toast.onRetry && { + onClick: () => { + discardToast(toast.id); + toast.onRetry?.(); + }, + label: intl.formatMessage(messages.toastRetryLabel), + }} + > + {toast.message} + + ))} +
+
+ ); +}; + +export const useToastManager = (): ToastManagerContextType => { + const context = useContext(ToastManagerContext); + if (!context) { + throw new Error('useToastManager must be used within a ToastManagerProvider'); + } + return context; +}; diff --git a/src/providers/messages.ts b/src/providers/messages.ts new file mode 100644 index 00000000..0b700d7f --- /dev/null +++ b/src/providers/messages.ts @@ -0,0 +1,10 @@ +import { defineMessages } from '@openedx/frontend-base'; + +const messages = defineMessages({ + toastRetryLabel: { + id: 'instruct.toast.retry.label', + defaultMessage: 'Retry', + description: 'Label for the retry action in toast notifications.', + }, +}); +export { messages }; diff --git a/src/routes.tsx b/src/routes.tsx index eb9c1458..f72d3817 100644 --- a/src/routes.tsx +++ b/src/routes.tsx @@ -1,4 +1,5 @@ import CourseInfoPage from './courseInfo/CourseInfoPage'; +import { DataDownloadsPage } from './dataDownloads/DataDownloadsPage'; import Main from './Main'; const routes = [ @@ -30,10 +31,10 @@ const routes = [ // path: 'student_admin', // element: // }, - // { - // path: 'data_download', - // element: - // }, + { + path: 'data_download', + element: + }, // { // path: 'special_exams', // element: diff --git a/src/testUtils.jsx b/src/testUtils.jsx deleted file mode 100644 index bd9610d8..00000000 --- a/src/testUtils.jsx +++ /dev/null @@ -1,7 +0,0 @@ -import { render } from '@testing-library/react'; -import { IntlProvider } from '@openedx/frontend-base'; - - -export const renderWithIntl = (component) => { - return render({ component }); -}; diff --git a/src/testUtils.tsx b/src/testUtils.tsx new file mode 100644 index 00000000..c8029e71 --- /dev/null +++ b/src/testUtils.tsx @@ -0,0 +1,28 @@ +import { render } from '@testing-library/react'; +import { IntlProvider } from '@openedx/frontend-base'; +import { ReactElement } from 'react'; +import { MemoryRouter } from 'react-router-dom'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { ToastManagerProvider } from './providers/ToastManagerProvider'; + +export const renderWithIntl = (component) => { + return render({ component }); +}; + +export const renderWithProviders = (component: ReactElement) => { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }); + + return render( + + + + + {component} + + + + + ); +}; diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 00000000..8caf58ea --- /dev/null +++ b/src/types.ts @@ -0,0 +1,5 @@ +export interface TableCellValue { + row: { + original: T, + }, +}