Skip to content
Draft
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
2 changes: 1 addition & 1 deletion src/cohorts/components/SelectedCohortInfo.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
65 changes: 65 additions & 0 deletions src/dataDownloads/DataDownloadsPage.test.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof useGeneratedReports>;
const mockUseGenerateReportLink = useGenerateReportLink as jest.MockedFunction<typeof useGenerateReportLink>;

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(
<IntlProvider locale="en">
<MemoryRouter initialEntries={[`/course/${courseId}/data-downloads`]}>
{component}
</MemoryRouter>
</IntlProvider>
);
};

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

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(<DataDownloadsPage />);
await user.click(screen.getByText('Download Report'));
expect(mockMutate).toHaveBeenCalledWith('https://example.com/report-a');
});
});
45 changes: 45 additions & 0 deletions src/dataDownloads/DataDownloadsPage.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Container className="mt-4.5 mb-4" fluid>
<h3>{intl.formatMessage(messages.dataDownloadsTitle)}</h3>
<p>{intl.formatMessage(messages.dataDownloadsDescription)}</p>
<p>{intl.formatMessage(messages.dataDownloadsReportExpirationPolicyMessage)}</p>
<DataDownloadTable data={data} isLoading={isLoading} onDownloadClick={handleDownload} />
</Container>
);
};

export { DataDownloadsPage };
70 changes: 70 additions & 0 deletions src/dataDownloads/components/DataDownloadTable.test.tsx
Original file line number Diff line number Diff line change
@@ -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(
<IntlProvider locale="en">
<DataDownloadTable {...props} />
</IntlProvider>
);
};

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();
});
});
50 changes: 50 additions & 0 deletions src/dataDownloads/components/DataDownloadTable.tsx
Original file line number Diff line number Diff line change
@@ -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 <DownloadLinkCell row={row} onDownloadClick={onDownloadClick} />;
}, [onDownloadClick]);

return (
<DataTable
columns={tableColumns}
data={data}
isLoading={isLoading}
additionalColumns={[
{
id: 'reportName',
Header: intl.formatMessage(messages.reportNameColumnName),
Cell: ReportNameCell,
},
{
id: 'downloadLink',
Header: '',
Cell: DownloadCustomCell,
}
]}
RowStatusComponent={() => null}
>
</DataTable>
);
};

export { DataDownloadTable };
69 changes: 69 additions & 0 deletions src/dataDownloads/components/DownloadLinkCell.test.tsx
Original file line number Diff line number Diff line change
@@ -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(
<IntlProvider locale="en">
<DownloadLinkCell
{...props}
/>
</IntlProvider>
);
};

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('');
});
});
21 changes: 21 additions & 0 deletions src/dataDownloads/components/DownloadLinkCell.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Button variant="link" size="sm" onClick={() => onDownloadClick(downloadLink)}>
{intl.formatMessage(messages.downloadLinkLabel)}
</Button>
);
};

export { DownloadLinkCell };
53 changes: 53 additions & 0 deletions src/dataDownloads/components/ReportNameCell.test.tsx
Original file line number Diff line number Diff line change
@@ -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(
<IntlProvider locale="en">
<ReportNameCell
{...props}
/>
</IntlProvider>
);
};

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');
});
});
Loading