diff --git a/src/cohorts/components/SelectedCohortInfo.tsx b/src/cohorts/components/SelectedCohortInfo.tsx index 6cae4baf..0fc08e1b 100644 --- a/src/cohorts/components/SelectedCohortInfo.tsx +++ b/src/cohorts/components/SelectedCohortInfo.tsx @@ -2,7 +2,7 @@ import { useIntl } from '@openedx/frontend-base'; import { useParams } from 'react-router-dom'; import CohortCard from './CohortCard'; import messages from '../messages'; -import dataDownloadsMessages from '../../dataDownloads/messages'; +import { messages as dataDownloadsMessages } from '../../dataDownloads/messages'; const SelectedCohortInfo = () => { const intl = useIntl(); diff --git a/src/dataDownloads/DataDownloadsPage.test.tsx b/src/dataDownloads/DataDownloadsPage.test.tsx new file mode 100644 index 00000000..bfb358d1 --- /dev/null +++ b/src/dataDownloads/DataDownloadsPage.test.tsx @@ -0,0 +1,65 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { IntlProvider } from '@openedx/frontend-base'; +import { MemoryRouter } from 'react-router-dom'; +import { DataDownloadsPage } from './DataDownloadsPage'; +import { useGeneratedReports, useGenerateReportLink } from './data/apiHook'; + +jest.mock('./data/apiHook'); + +const mockUseGeneratedReports = useGeneratedReports as jest.MockedFunction; +const mockUseGenerateReportLink = useGenerateReportLink as jest.MockedFunction; + +const mockReportsData = [ + { + dateGenerated: '2025-10-01T12:00:00Z', + reportType: 'Type A', + reportName: 'Test Report A', + downloadLink: 'https://example.com/report-a', + }, +]; + +const renderWithProviders = (component: React.ReactElement, courseId = 'course-123') => { + return render( + + + {component} + + + ); +}; + +describe('DataDownloadsPage', () => { + const mockMutate = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + mockUseGenerateReportLink.mockReturnValue({ + mutate: mockMutate, + } 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..040abbb7 --- /dev/null +++ b/src/dataDownloads/DataDownloadsPage.tsx @@ -0,0 +1,45 @@ +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'; + +// 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)}

+ +
+ ); +}; + +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/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/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..033c7d51 --- /dev/null +++ b/src/dataDownloads/data/api.ts @@ -0,0 +1,16 @@ +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); +}; 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..c7621d1b --- /dev/null +++ b/src/dataDownloads/data/apiHook.ts @@ -0,0 +1,21 @@ +import { useMutation, useQuery } from '@tanstack/react-query'; +import { generateReportLink, getGeneratedReports } from './api'; + +export const queryKeys = { + generatedReports: (courseId: string) => ['generated-reports', courseId], + generateReportLink: (courseId: string) => ['report-link', 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), + }) +); diff --git a/src/dataDownloads/messages.ts b/src/dataDownloads/messages.ts index bbafec96..212fc5d3 100644 --- a/src/dataDownloads/messages.ts +++ b/src/dataDownloads/messages.ts @@ -6,6 +6,41 @@ const messages = defineMessages({ defaultMessage: 'Data Downloads', description: 'Title for the data downloads page' }, + 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', + } }); -export default messages; +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/routes.tsx b/src/routes.tsx index 96c1ac98..780b361d 100644 --- a/src/routes.tsx +++ b/src/routes.tsx @@ -1,5 +1,6 @@ import CohortsPage from './cohorts/CohortsPage'; import CourseInfoPage from './courseInfo/CourseInfoPage'; +import { DataDownloadsPage } from './dataDownloads/DataDownloadsPage'; import Main from './Main'; const routes = [ @@ -31,10 +32,10 @@ const routes = [ // path: 'student_admin', // element: // }, - // { - // path: 'data_download', - // element: - // }, + { + path: 'data_download', + element: + }, // { // path: 'special_exams', // element: 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, + }, +}