Skip to content

Commit 389880d

Browse files
feat: grading tools
1 parent 16a821d commit 389880d

File tree

13 files changed

+308
-3
lines changed

13 files changed

+308
-3
lines changed

src/components/SpecifyProblem.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
const SpecifyProblem = () => {
2+
return <div>Specify Problem</div>;
3+
};
4+
5+
export default SpecifyProblem;

src/courseInfo/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ export interface CourseInfoResponse {
2121
gradeCutoffs: string | null,
2222
staffCount: number,
2323
learnerCount: number,
24+
gradebookUrl: string,
25+
studioGradingUrl?: string,
2426
}
2527

2628
interface EnrollmentCounts extends Record<string, number> {

src/data/apiHook.test.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ const mockCourseData = {
2929
gradeCutoffs: null,
3030
staffCount: 5,
3131
learnerCount: 145,
32+
gradebookUrl: 'http://example.com/gradebook',
3233
};
3334

3435
const createWrapper = () => {

src/grading/GradingPage.tsx

Lines changed: 36 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,41 @@
1+
import { useState } from 'react';
2+
import { useIntl } from '@openedx/frontend-base';
3+
import { Button, ButtonGroup, Card } from '@openedx/paragon';
4+
import GradingLearnerContent from './components/GradingLearnerContent';
5+
import messages from './messages';
6+
import GradingActionRow from './components/GradingActionRow';
7+
import { GradingToolsType } from './types';
8+
import { PendingTasks } from '@src/components/PendingTasks';
9+
110
const GradingPage = () => {
11+
const intl = useIntl();
12+
const [selectedTools, setSelectedTools] = useState<GradingToolsType>('single');
13+
214
return (
3-
<div>
4-
<h3>Grading</h3>
5-
</div>
15+
<>
16+
<div className="d-flex justify-content-between align-items-center">
17+
<h3 className="text-primary-700">{intl.formatMessage(messages.pageTitle)}</h3>
18+
<GradingActionRow />
19+
</div>
20+
<Card className="bg-light-200 p-4 mt-4.5">
21+
<ButtonGroup className="d-block">
22+
<Button
23+
onClick={() => setSelectedTools('single')}
24+
variant={selectedTools === 'single' ? 'primary' : 'outline-primary'}
25+
>
26+
{intl.formatMessage(messages.singleLearner)}
27+
</Button>
28+
<Button
29+
onClick={() => setSelectedTools('all')}
30+
variant={selectedTools === 'all' ? 'primary' : 'outline-primary'}
31+
>
32+
{intl.formatMessage(messages.allLearners)}
33+
</Button>
34+
</ButtonGroup>
35+
<GradingLearnerContent toolType={selectedTools} />
36+
</Card>
37+
<PendingTasks />
38+
</>
639
);
740
};
841

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import { screen } from '@testing-library/react';
2+
import userEvent from '@testing-library/user-event';
3+
import { renderWithIntl } from '@src/testUtils';
4+
import GradingActionRow from '@src/grading/components/GradingActionRow';
5+
import messages from '../messages';
6+
import { useCourseInfo } from '@src/data/apiHook';
7+
import { useGradingConfiguration } from '../data/apiHook';
8+
9+
jest.mock('react-router-dom', () => ({
10+
...jest.requireActual('react-router-dom'),
11+
useParams: () => ({
12+
courseId: 'course-v1:edX+DemoX+Demo_Course',
13+
}),
14+
}));
15+
16+
jest.mock('@src/data/apiHook', () => ({
17+
useCourseInfo: jest.fn(),
18+
}));
19+
20+
jest.mock('@src/grading/data/apiHook', () => ({
21+
useGradingConfiguration: jest.fn(),
22+
}));
23+
24+
describe('GradingActionRow', () => {
25+
beforeEach(() => {
26+
jest.clearAllMocks();
27+
(useCourseInfo as jest.Mock).mockReturnValue({ data: { gradebookUrl: 'https://example.com/gradebook', studioGradingUrl: 'https://example.com/studio' } });
28+
// TODO: Update this mock to use similar structure when API is ready, currently just returning random text to ensure component renders without error
29+
(useGradingConfiguration as jest.Mock).mockReturnValue({ data: 'Some random text' });
30+
});
31+
32+
it('renders ActionRow with gradebook and configuration buttons', () => {
33+
renderWithIntl(<GradingActionRow />);
34+
expect(screen.getByRole('link', { name: messages.viewGradebook.defaultMessage })).toBeInTheDocument();
35+
expect(screen.getByRole('button', { name: messages.configurationAlt.defaultMessage })).toBeInTheDocument();
36+
});
37+
38+
it('opens configuration menu when configuration button is clicked', async () => {
39+
renderWithIntl(<GradingActionRow />);
40+
const user = userEvent.setup();
41+
await user.click(screen.getByRole('button', { name: messages.configurationAlt.defaultMessage }));
42+
expect(screen.getByText('View Grading Configuration')).toBeInTheDocument();
43+
expect(screen.getByText('View Course Grading Settings')).toBeInTheDocument();
44+
});
45+
46+
it('opens and closes GradingConfigurationModal when menu item is clicked', async () => {
47+
renderWithIntl(<GradingActionRow />);
48+
const user = userEvent.setup();
49+
await user.click(screen.getByRole('button', { name: messages.configurationAlt.defaultMessage }));
50+
const gradingConfigButton = screen.getByText('View Grading Configuration');
51+
await user.click(gradingConfigButton);
52+
expect(screen.getByRole('dialog', { name: messages.gradingConfiguration.defaultMessage })).toBeInTheDocument();
53+
54+
// Close modal
55+
await user.click(screen.getAllByRole('button', { name: messages.close.defaultMessage })[0]);
56+
expect(screen.queryByRole('dialog', { name: messages.gradingConfiguration.defaultMessage })).not.toBeInTheDocument();
57+
});
58+
});
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import { useState } from 'react';
2+
import { useParams } from 'react-router-dom';
3+
import { useIntl } from '@openedx/frontend-base';
4+
import { useToggle, ActionRow, Button, IconButton, ModalPopup, Menu, MenuItem } from '@openedx/paragon';
5+
import { TrendingUp, MoreVert, OpenInNew } from '@openedx/paragon/icons';
6+
import { useCourseInfo } from '@src/data/apiHook';
7+
import messages from '../messages';
8+
import GradingConfigurationModal from './GradingConfigurationModal';
9+
10+
const GradingActionRow = () => {
11+
const { courseId = '' } = useParams<{ courseId: string }>();
12+
const intl = useIntl();
13+
const { data = { gradebookUrl: '', studioGradingUrl: '' } } = useCourseInfo(courseId);
14+
const [configurationMenuTarget, setConfigurationMenuTarget] = useState<HTMLButtonElement | null>(null);
15+
const [isOpenMenu, openMenu, closeMenu] = useToggle(false);
16+
const [isOpenConfigModal, openConfigModal, closeConfigModal] = useToggle(false);
17+
18+
const handleConfigurationMenuClick = (event: React.MouseEvent<HTMLButtonElement>) => {
19+
setConfigurationMenuTarget(event?.currentTarget);
20+
openMenu();
21+
};
22+
23+
const handleConfigModalOpen = () => {
24+
openConfigModal();
25+
closeMenu();
26+
};
27+
28+
return (
29+
<>
30+
<ActionRow>
31+
<Button as="a" href={data.gradebookUrl} iconBefore={TrendingUp} variant="outline-primary">{intl.formatMessage(messages.viewGradebook)}</Button>
32+
<IconButton
33+
alt={intl.formatMessage(messages.configurationAlt)}
34+
className="lead"
35+
iconAs={MoreVert}
36+
onClick={handleConfigurationMenuClick}
37+
/>
38+
</ActionRow>
39+
<ModalPopup positionRef={configurationMenuTarget} onClose={closeMenu} isOpen={isOpenMenu}>
40+
<Menu>
41+
<MenuItem onClick={handleConfigModalOpen}>
42+
{intl.formatMessage(messages.viewGradingConfiguration)}
43+
</MenuItem>
44+
<MenuItem iconAfter={OpenInNew} as="a" href={data.studioGradingUrl} target="_blank">
45+
{intl.formatMessage(messages.viewCourseGradingSettings)}
46+
</MenuItem>
47+
</Menu>
48+
</ModalPopup>
49+
<GradingConfigurationModal isOpen={isOpenConfigModal} onClose={closeConfigModal} />
50+
</>
51+
);
52+
};
53+
54+
export default GradingActionRow;
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { useParams } from 'react-router-dom';
2+
import { Button, ModalDialog } from '@openedx/paragon';
3+
import { useIntl } from '@openedx/frontend-base';
4+
import messages from '../messages';
5+
import { useGradingConfiguration } from '../data/apiHook';
6+
7+
interface GradingConfigurationModalProps {
8+
isOpen: boolean,
9+
onClose: () => void,
10+
}
11+
12+
const GradingConfigurationModal = ({ isOpen, onClose }: GradingConfigurationModalProps) => {
13+
const intl = useIntl();
14+
const { courseId = '' } = useParams<{ courseId: string }>();
15+
const { data = null } = useGradingConfiguration(courseId);
16+
17+
return (
18+
<ModalDialog title={intl.formatMessage(messages.gradingConfiguration)} isOpen={isOpen} onClose={onClose} isOverflowVisible={false}>
19+
<ModalDialog.Header>
20+
<h3>{intl.formatMessage(messages.gradingConfiguration)}</h3>
21+
</ModalDialog.Header>
22+
<ModalDialog.Body>
23+
<p>{data ?? intl.formatMessage(messages.noGradingConfiguration)}</p>
24+
</ModalDialog.Body>
25+
<ModalDialog.Footer>
26+
<Button onClick={onClose}>{intl.formatMessage(messages.close)}</Button>
27+
</ModalDialog.Footer>
28+
</ModalDialog>
29+
);
30+
};
31+
32+
export default GradingConfigurationModal;
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { useIntl } from '@openedx/frontend-base';
2+
import messages from '../messages';
3+
import SpecifyProblem from '../../components/SpecifyProblem';
4+
import { GradingToolsType } from '../types';
5+
6+
interface GradingLearnerContentProps {
7+
toolType: GradingToolsType,
8+
}
9+
10+
const GradingLearnerContent = ({ toolType }: GradingLearnerContentProps) => {
11+
const intl = useIntl();
12+
13+
return (
14+
<>
15+
<p className="x-small text-primary mt-3">
16+
{
17+
toolType === 'single'
18+
? intl.formatMessage(messages.descriptionSingleLearner)
19+
: intl.formatMessage(messages.descriptionAllLearners)
20+
}
21+
</p>
22+
<SpecifyProblem />
23+
</>
24+
);
25+
};
26+
27+
export default GradingLearnerContent;

src/grading/data/api.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import { camelCaseObject, getAuthenticatedHttpClient } from '@openedx/frontend-base';
2+
import { getApiBaseUrl } from '@src/data/api';
3+
4+
export const getGradingConfiguration = async (courseId: string) => {
5+
const { data } = await getAuthenticatedHttpClient().get(`${getApiBaseUrl()}/api/instructor/v2/courses/${courseId}/grading_configuration`
6+
);
7+
return camelCaseObject(data);
8+
};

src/grading/data/apiHook.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import { useQuery } from '@tanstack/react-query';
2+
import { getGradingConfiguration } from './api';
3+
import { gradingQueryKeys } from './queryKeys';
4+
5+
export const useGradingConfiguration = (courseId: string) => (
6+
useQuery({
7+
queryKey: gradingQueryKeys.gradingConfiguration(courseId),
8+
queryFn: () => getGradingConfiguration(courseId),
9+
enabled: !!courseId,
10+
})
11+
);

0 commit comments

Comments
 (0)