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
11 changes: 7 additions & 4 deletions src/Main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,17 +5,20 @@ import { appId } from './constants';
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';
import './main.scss';

const queryClient = new QueryClient();

const Main = () => (
<CurrentAppProvider appId={appId}>
<QueryClientProvider client={queryClient}>
<main>
<Outlet />
{ getAppConfig(appId).NODE_ENV === 'development' && <ReactQueryDevtools initialIsOpen={false} /> }
</main>
<ToastManagerProvider>
<main>
<Outlet />
{ getAppConfig(appId).NODE_ENV === 'development' && <ReactQueryDevtools initialIsOpen={false} /> }
</main>
</ToastManagerProvider>
</QueryClientProvider>
</CurrentAppProvider>
);
Expand Down
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 '@src/dataDownloads/messages';
import { messages as dataDownloadsMessages } from '@src/dataDownloads/messages';

const SelectedCohortInfo = () => {
const intl = useIntl();
Expand Down
1 change: 1 addition & 0 deletions src/constants.ts
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export const appId = 'org.openedx.frontend.app.instructor';
export const DEFAULT_TOAST_DELAY = 5000; // in milliseconds
2 changes: 1 addition & 1 deletion src/data/api.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { camelCaseObject, getAppConfig, getAuthenticatedHttpClient } from '@openedx/frontend-base';
import { appId } from '@src/constants';

export const getApiBaseUrl = () => getAppConfig(appId).LMS_BASE_URL;
export const getApiBaseUrl = () => getAppConfig(appId).LMS_BASE_URL as string;

/**
* Get course settings.
Expand Down
66 changes: 66 additions & 0 deletions src/dataDownloads/DataDownloadsPage.test.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof useGeneratedReports>;
const mockUseGenerateReportLink = useGenerateReportLink as jest.MockedFunction<typeof useGenerateReportLink>;
const mockUseTriggerReportGeneration = useTriggerReportGeneration as jest.MockedFunction<typeof useTriggerReportGeneration>;

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(<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');
});
});
53 changes: 53 additions & 0 deletions src/dataDownloads/DataDownloadsPage.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Container className="mt-4.5 mb-4" fluid="xl">
<section>
<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} />
</section>
<section className="mt-5">
<h3>{intl.formatMessage(messages.dataDownloadsGenerateReportTitle)}</h3>
<p>{intl.formatMessage(messages.dataDownloadsGenerateReportDescription)}</p>
<ReportGenerationTabs />
</section>
</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 };
Loading