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
14 changes: 14 additions & 0 deletions src/openResponses/OpenResponsesPage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { Container } from '@openedx/paragon';
import ORSummary from './components/ORSummary';
import DetailAssessmentsList from './components/DetailAssessmentsList';

const OpenResponsesPage = () => {
return (
<Container fluid>
<ORSummary />
<DetailAssessmentsList />
</Container>
);
};

export default OpenResponsesPage;
83 changes: 83 additions & 0 deletions src/openResponses/components/DetailAssessmentsList.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { screen, fireEvent, waitFor } from '@testing-library/react';
import DetailAssessmentsList from './DetailAssessmentsList';
import { useDetailAssessmentsData } from '../../data/apiHook';
import { renderWithIntl } from '../../testUtils';

jest.mock('react-router-dom', () => ({
useParams: () => ({ courseId: 'course-v1:edX+Test+2024' }),
}));

jest.mock('../../data/apiHook', () => ({
useDetailAssessmentsData: jest.fn(),
}));

const mockResults = [
{
id: '1',
unitName: 'Unit 1',
assessment: 'Assessment 1',
totalResponses: 2,
training: 0,
peer: 1,
self: 0,
waiting: 0,
staff: 0,
finalGradeReceived: 1,
url: 'http://test-url.com',
},
];

describe('DetailAssessmentsList', () => {
it('renders loading state', () => {
(useDetailAssessmentsData as jest.Mock).mockReturnValue({
data: { count: 0, results: [] },
isLoading: true,
});
renderWithIntl(<DetailAssessmentsList />);
expect(screen.getByRole('table')).toBeInTheDocument();
});

it('renders table with data', () => {
(useDetailAssessmentsData as jest.Mock).mockReturnValue({
data: { count: 1, results: mockResults },
isLoading: false,
});
renderWithIntl(<DetailAssessmentsList />);
expect(screen.getByText(mockResults[0].unitName)).toBeInTheDocument();
expect(screen.getByText(mockResults[0].assessment)).toBeInTheDocument();
expect(screen.getByRole('link', { name: /View and Grade Responses/i })).toBeInTheDocument();
});

it('renders correct number of columns', () => {
(useDetailAssessmentsData as jest.Mock).mockReturnValue({
data: { count: 1, results: mockResults },
isLoading: false,
});
renderWithIntl(<DetailAssessmentsList />);
expect(screen.getAllByRole('columnheader')).toHaveLength(10);
});

it('calls fetchData on page change', async () => {
(useDetailAssessmentsData as jest.Mock).mockReturnValue({
data: { count: 30, results: mockResults },
isLoading: false,
});
renderWithIntl(<DetailAssessmentsList />);
const nextButton = screen.getByLabelText(/next/i);
fireEvent.click(nextButton);
await waitFor(() => {
expect(useDetailAssessmentsData).toHaveBeenCalled();
});
});

it('renders empty state when no data', () => {
(useDetailAssessmentsData as jest.Mock).mockReturnValue({
data: { count: 0, results: [] },
isLoading: false,
});
renderWithIntl(<DetailAssessmentsList />);
expect(screen.getByRole('table')).toBeInTheDocument();
expect(screen.getByText('No results found')).toBeInTheDocument();
expect(screen.queryByText('View and Grade Responses')).not.toBeInTheDocument();
});
});
87 changes: 87 additions & 0 deletions src/openResponses/components/DetailAssessmentsList.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import { useState } from 'react';
import { useParams } from 'react-router-dom';
import { useIntl } from '@openedx/frontend-base';
import { Button, DataTable } from '@openedx/paragon';
import messages from '../messages';
import { useDetailAssessmentsData } from '../../data/apiHook';

const DETAILS_PAGE_SIZE = 25;

interface DataTableFetchDataProps {
pageIndex: number,
}

// Example of api response to test on UI
// const mockResults = [
// {
// id: '1',
// unitName: 'Example Unit',
// assessment: 'ORA block number 1',
// totalResponses: 2,
// training: 0,
// peer: 1,
// self: 0,
// waiting: 0,
// staff: 0,
// finalGradeReceived: 1,
// url: 'http://apps.local.openedx.io:8080/instructor/course-v1:DV-edtech+check+2025-05/open_responses'
// }
// ];

const DetailAssessmentsList = () => {
const intl = useIntl();
const { courseId } = useParams();
const [page, setPage] = useState(0);
const { data = { count: 0, results: [] }, isLoading } = useDetailAssessmentsData(courseId ?? '', {
page,
pageSize: DETAILS_PAGE_SIZE
});

const pageCount = Math.ceil(data.count / DETAILS_PAGE_SIZE);

const tableColumns = [
{ accessor: 'unitName', Header: intl.formatMessage(messages.unitName) },
{ accessor: 'assessment', Header: intl.formatMessage(messages.assessment) },
{ accessor: 'totalResponses', Header: intl.formatMessage(messages.totalResponses) },
{ accessor: 'training', Header: intl.formatMessage(messages.training) },
{ accessor: 'peer', Header: intl.formatMessage(messages.peer) },
{ accessor: 'self', Header: intl.formatMessage(messages.self) },
{ accessor: 'waiting', Header: intl.formatMessage(messages.waiting) },
{ accessor: 'staff', Header: intl.formatMessage(messages.staff) },
{ accessor: 'finalGradeReceived', Header: intl.formatMessage(messages.finalGradeReceived) },
{ accessor: 'staffGrader', Header: intl.formatMessage(messages.staffGrader) }
];

const handleFetchData = (data: DataTableFetchDataProps) => {
setPage(data.pageIndex);
};

const tableData = data.results.map(item => ({
...item,
staffGrader: <Button variant="link" size="inline" href={item.url}>View and Grade Responses</Button>,
}));

return (
<div className="mt-4.5">
<h3>{intl.formatMessage(messages.details)}</h3>
<DataTable
columns={tableColumns}
data={tableData}
fetchData={handleFetchData}
initialState={{
pageIndex: page,
pageSize: DETAILS_PAGE_SIZE
}}
isLoading={isLoading}
isPaginated
itemCount={data.count}
manualFilters
manualPagination
pageCount={pageCount}
pageSize={DETAILS_PAGE_SIZE}
/>
</div>
);
};

export default DetailAssessmentsList;
82 changes: 82 additions & 0 deletions src/openResponses/components/ORSummary.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import { screen } from '@testing-library/react';
import { useParams } from 'react-router-dom';
import ORSummary from './ORSummary';
import { useOpenResponsesData } from '../../data/apiHook';
import messages from '../messages';
import { renderWithIntl } from '../../testUtils';

jest.mock('react-router-dom', () => ({
useParams: jest.fn(),
}));

jest.mock('../../data/apiHook', () => ({
useOpenResponsesData: jest.fn(),
}));

const mockData = {
totalUnits: '5',
totalAssessments: '10',
totalResponses: '15',
training: '2',
peer: '3',
self: '4',
waiting: '1',
staff: '6',
finalGradeReceived: '7',
};

describe('ORSummary', () => {
beforeEach(() => {
(useParams as jest.Mock).mockReturnValue({ courseId: 'course-v1:edX+Test+2024' });
});

it('renders all summary titles', () => {
(useOpenResponsesData as jest.Mock).mockReturnValue({ data: {} });
renderWithIntl(<ORSummary />);
expect(screen.getByText(messages.summaryTitle.defaultMessage)).toBeInTheDocument();
expect(screen.getByText(messages.totalUnits.defaultMessage)).toBeInTheDocument();
expect(screen.getByText(messages.totalAssessments.defaultMessage)).toBeInTheDocument();
expect(screen.getByText(messages.totalResponses.defaultMessage)).toBeInTheDocument();
expect(screen.getByText(messages.training.defaultMessage)).toBeInTheDocument();
expect(screen.getByText(messages.peer.defaultMessage)).toBeInTheDocument();
expect(screen.getByText(messages.self.defaultMessage)).toBeInTheDocument();
expect(screen.getByText(messages.waiting.defaultMessage)).toBeInTheDocument();
expect(screen.getByText(messages.staff.defaultMessage)).toBeInTheDocument();
expect(screen.getByText(messages.finalGradeReceived.defaultMessage)).toBeInTheDocument();
});

it('renders default values when data is empty', () => {
(useOpenResponsesData as jest.Mock).mockReturnValue({ data: {} });
renderWithIntl(<ORSummary />);
expect(screen.getAllByText('0').length).toBe(9);
});

it('renders values from data', () => {
(useOpenResponsesData as jest.Mock).mockReturnValue({
data: mockData,
});
renderWithIntl(<ORSummary />);
expect(screen.getByText('5')).toBeInTheDocument();
expect(screen.getByText('10')).toBeInTheDocument();
expect(screen.getByText('15')).toBeInTheDocument();
expect(screen.getByText('2')).toBeInTheDocument();
expect(screen.getByText('3')).toBeInTheDocument();
expect(screen.getByText('4')).toBeInTheDocument();
expect(screen.getByText('1')).toBeInTheDocument();
expect(screen.getByText('6')).toBeInTheDocument();
expect(screen.getByText('7')).toBeInTheDocument();
});

it('renders icons', () => {
(useOpenResponsesData as jest.Mock).mockReturnValue({ data: mockData });
renderWithIntl(<ORSummary />);
expect(screen.getAllByRole('img', { hidden: true }).length).toBe(2);
});

it('uses courseId from params', () => {
(useParams as jest.Mock).mockReturnValue({ courseId: 'course-v1:edX+Another+2024' });
(useOpenResponsesData as jest.Mock).mockReturnValue({ data: {} });
renderWithIntl(<ORSummary />);
expect(useOpenResponsesData).toHaveBeenCalledWith('course-v1:edX+Another+2024');
});
});
99 changes: 99 additions & 0 deletions src/openResponses/components/ORSummary.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import { useParams } from 'react-router-dom';
import { useIntl } from '@openedx/frontend-base';
import { Icon } from '@openedx/paragon';
import { EditNote, ViewDay } from '@openedx/paragon/icons';
import { useOpenResponsesData } from '../../data/apiHook';
import messages from '../messages';

const ORSummary = () => {
const intl = useIntl();
const { courseId = '' } = useParams<{ courseId: string }>();
const { data = {} } = useOpenResponsesData(courseId);
const {
totalUnits = '0',
totalAssessments = '0',
totalResponses = '0',
training = '0',
peer = '0',
self = '0',
waiting = '0',
staff = '0',
finalGradeReceived = '0',
} = data;

return (
<>
<h3>{intl.formatMessage(messages.summaryTitle)}</h3>
<div className="container-mw-xl">
<div className="row">
<div className="col-sm-3 col-md-2 col-xl-1">
<p className="mb-2">{intl.formatMessage(messages.totalUnits)}</p>
</div>
<div className="col-sm-4 col-md-3 col-xl-2">
<p className="mb-2">{intl.formatMessage(messages.totalAssessments)}</p>
</div>
</div>
<div className="row lead">
<div className="col-sm-3 col-md-2 col-xl-1 d-flex align-items-center">
<Icon src={ViewDay} />
<p className="ml-2 mb-0">{totalUnits}</p>
</div>
<div className="col-sm-4 col-md-3 col-xl-2 d-flex align-items-center">
<Icon src={EditNote} size="lg" />
<p className="ml-2 mb-0">{totalAssessments}</p>
</div>
</div>
</div>
<div className="container-mw-xl mt-3">
<div className="row align-items-end">
<div className="col-sm-2 col-lg-1">
<p className="mb-2">{intl.formatMessage(messages.totalResponses)}</p>
</div>
<div className="col-sm-2 col-lg-1">
<p className="mb-2">{intl.formatMessage(messages.training)}</p>
</div>
<div className="col-sm-1">
<p className="mb-2">{intl.formatMessage(messages.peer)}</p>
</div>
<div className="col-sm-1">
<p className="mb-2">{intl.formatMessage(messages.self)}</p>
</div>
<div className="col-sm-2 col-lg-1">
<p className="mb-2">{intl.formatMessage(messages.waiting)}</p>
</div>
<div className="col-sm-2 col-lg-1">
<p className="mb-2">{intl.formatMessage(messages.staff)}</p>
</div>
<div className="col-sm-2 col-lg-1">
<p className="mb-2">{intl.formatMessage(messages.finalGradeReceived)}</p>
</div>
</div>
<div className="row lead">
<div className="col-sm-2 col-lg-1">
<p>{totalResponses}</p>
</div>
<div className="col-sm-2 col-lg-1">
<p>{training}</p>
</div>
<div className="col-sm-1">
<p>{peer}</p>
</div>
<div className="col-sm-1">
<p>{self}</p>
</div>
<div className="col-sm-2 col-lg-1">
<p>{waiting}</p>
</div>
<div className="col-sm-2 col-lg-1">
<p>{staff}</p>
</div>
<div className="col-sm-2 col-lg-1">
<p>{finalGradeReceived}</p>
</div>
</div>
</div>
</>
);
};

export default ORSummary;
17 changes: 17 additions & 0 deletions src/openResponses/data/api.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { getAuthenticatedHttpClient, camelCaseObject } from '@openedx/frontend-base';
import { getApiBaseUrl } from '../../data/api';

export const getOpenResponsesData = async (courseId: string) => {
const url = `${getApiBaseUrl()}/api/instructor/v2/courses/${courseId}/open-responses`;
const { data } = await getAuthenticatedHttpClient().get(url);
return camelCaseObject(data);
};

export const getDetailAssessmentsData = async (
courseId: string,
params: Record<string, string | number | boolean> = {},
) => {
const url = `${getApiBaseUrl()}/api/instructor/v2/courses/${courseId}/open-responses/assessments`;
const { data } = await getAuthenticatedHttpClient().get(url, { params });
return camelCaseObject(data);
};
Loading