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 @@ -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 = () => (
<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
73 changes: 73 additions & 0 deletions src/components/ActionCard.test.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof userEvent.setup>;
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(<ActionCard {...defaultProps} />);
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(
<ActionCard
{...defaultProps}
onButtonClick={mockOnClick}
/>
);
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(
<ActionCard
{...defaultProps}
isLoading={true}
/>
);
const button = screen.getByRole('button', { name: 'Click Me' });
expect(button).toBeDisabled();
});

it('should render custom action instead of default button when provided', () => {
const CustomAction = () => (
<div>
<button>Custom Button 1</button>
<button>Custom Button 2</button>
</div>
);
render(
<ActionCard
{...defaultProps}
customAction={<CustomAction />}
/>
);
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(<ActionCard {...defaultProps} />);
const button = screen.getByRole('button', { name: 'Click Me' });
await user.click(button);
expect(button).toBeInTheDocument();
});
});
43 changes: 43 additions & 0 deletions src/components/ActionCard.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Card className="action-card py-2" orientation="horizontal">
<Card.Body className="flex-grow-1">
<Card.Section>
<h4 className="mb-2">{title}</h4>
<p className="text-muted mb-0">{description}</p>
</Card.Section>
</Card.Body>
<Card.Footer className="d-flex align-items-center justify-content-end">
{customAction ?? (
<Button
onClick={onButtonClick}
disabled={isLoading}
variant="primary"
>
{buttonLabel}
</Button>
)}
</Card.Footer>
</Card>
);
};

export { ActionCard };
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
Expand Up @@ -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;
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 };
Loading
Loading