Skip to content

Commit 8e803d7

Browse files
feat: grading tools
1 parent ddaa1ac commit 8e803d7

15 files changed

Lines changed: 499 additions & 4 deletions

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
@@ -26,6 +26,8 @@ export interface CourseInfoResponse {
2626
dataResearcher: boolean,
2727
[key: string]: boolean,
2828
},
29+
gradebookUrl: string,
30+
studioGradingUrl?: string,
2931
}
3032

3133
interface EnrollmentCounts extends Record<string, number> {

src/data/apiHook.test.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,8 @@ const mockCourseData = {
3333
permissions: {
3434
admin: true,
3535
dataResearcher: false,
36-
}
36+
},
37+
gradebookUrl: 'http://example.com/gradebook',
3738
};
3839

3940
const createWrapper = () => {

src/grading/GradingPage.test.tsx

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
import { screen } from '@testing-library/react';
2+
import userEvent from '@testing-library/user-event';
3+
import { renderWithIntl } from '@src/testUtils';
4+
import GradingPage from './GradingPage';
5+
import messages from './messages';
6+
7+
// Mock child components, each component should have its own test suite
8+
jest.mock('./components/GradingLearnerContent', () => {
9+
return function MockGradingLearnerContent({ toolType }: { toolType: string }) {
10+
return <div>Grading Content for: {toolType}</div>;
11+
};
12+
});
13+
14+
jest.mock('./components/GradingActionRow', () => {
15+
return function MockGradingActionRow() {
16+
return <div>Grading Action Row</div>;
17+
};
18+
});
19+
20+
jest.mock('@src/components/PendingTasks', () => {
21+
return {
22+
PendingTasks: function MockPendingTasks() {
23+
return <div>Pending Tasks</div>;
24+
}
25+
};
26+
});
27+
28+
describe('GradingPage', () => {
29+
beforeEach(() => {
30+
jest.clearAllMocks();
31+
});
32+
33+
it('renders the page title correctly', () => {
34+
renderWithIntl(<GradingPage />);
35+
expect(screen.getByText(messages.pageTitle.defaultMessage)).toBeInTheDocument();
36+
});
37+
38+
it('renders all child components', () => {
39+
renderWithIntl(<GradingPage />);
40+
expect(screen.getByText('Grading Action Row')).toBeInTheDocument();
41+
expect(screen.getByText('Grading Content for: single')).toBeInTheDocument();
42+
expect(screen.getByText('Pending Tasks')).toBeInTheDocument();
43+
});
44+
45+
it('renders both button options with correct labels', () => {
46+
renderWithIntl(<GradingPage />);
47+
expect(screen.getByRole('button', { name: messages.singleLearner.defaultMessage })).toBeInTheDocument();
48+
expect(screen.getByRole('button', { name: messages.allLearners.defaultMessage })).toBeInTheDocument();
49+
});
50+
51+
it('defaults to single learner tool being selected', () => {
52+
renderWithIntl(<GradingPage />);
53+
const singleLearnerButton = screen.getByRole('button', { name: messages.singleLearner.defaultMessage });
54+
const allLearnersButton = screen.getByRole('button', { name: messages.allLearners.defaultMessage });
55+
56+
// Single learner should have primary variant (selected state)
57+
expect(singleLearnerButton).toHaveClass('btn-primary');
58+
expect(allLearnersButton).toHaveClass('btn-outline-primary');
59+
60+
// GradingLearnerContent should receive 'single' as initial toolType
61+
expect(screen.getByText('Grading Content for: single')).toBeInTheDocument();
62+
});
63+
64+
it('switches to All Learners when All Learners button is clicked', async () => {
65+
renderWithIntl(<GradingPage />);
66+
const user = userEvent.setup();
67+
68+
const allLearnersButton = screen.getByRole('button', { name: messages.allLearners.defaultMessage });
69+
70+
await user.click(allLearnersButton);
71+
72+
// All learners should now be selected
73+
expect(allLearnersButton).toHaveClass('btn-primary');
74+
expect(screen.getByRole('button', { name: messages.singleLearner.defaultMessage })).toHaveClass('btn-outline-primary');
75+
76+
// GradingLearnerContent should receive 'all' as toolType
77+
expect(screen.getByText('Grading Content for: all')).toBeInTheDocument();
78+
});
79+
80+
it('switches back to Single Learner when Single Learner button is clicked', async () => {
81+
renderWithIntl(<GradingPage />);
82+
const user = userEvent.setup();
83+
84+
const singleLearnerButton = screen.getByRole('button', { name: messages.singleLearner.defaultMessage });
85+
const allLearnersButton = screen.getByRole('button', { name: messages.allLearners.defaultMessage });
86+
87+
// First switch to all learners
88+
await user.click(allLearnersButton);
89+
expect(screen.getByText('Grading Content for: all')).toBeInTheDocument();
90+
91+
// Then switch back to single learner
92+
await user.click(singleLearnerButton);
93+
94+
// Single learner should be selected again
95+
expect(singleLearnerButton).toHaveClass('btn-primary');
96+
expect(allLearnersButton).toHaveClass('btn-outline-primary');
97+
expect(screen.getByText('Grading Content for: single')).toBeInTheDocument();
98+
});
99+
100+
it('maintains correct button states during multiple interactions', async () => {
101+
renderWithIntl(<GradingPage />);
102+
const user = userEvent.setup();
103+
104+
const singleLearnerButton = screen.getByRole('button', { name: messages.singleLearner.defaultMessage });
105+
const allLearnersButton = screen.getByRole('button', { name: messages.allLearners.defaultMessage });
106+
107+
// Initial state - single learner selected
108+
expect(singleLearnerButton).toHaveClass('btn-primary');
109+
expect(allLearnersButton).toHaveClass('btn-outline-primary');
110+
111+
// Click all learners multiple times - should remain selected
112+
await user.click(allLearnersButton);
113+
await user.click(allLearnersButton);
114+
115+
expect(allLearnersButton).toHaveClass('btn-primary');
116+
expect(singleLearnerButton).toHaveClass('btn-outline-primary');
117+
expect(screen.getByText('Grading Content for: all')).toBeInTheDocument();
118+
119+
// Click single learner multiple times - should remain selected
120+
await user.click(singleLearnerButton);
121+
await user.click(singleLearnerButton);
122+
123+
expect(singleLearnerButton).toHaveClass('btn-primary');
124+
expect(allLearnersButton).toHaveClass('btn-outline-primary');
125+
expect(screen.getByText('Grading Content for: single')).toBeInTheDocument();
126+
});
127+
128+
it('passes correct toolType prop to GradingLearnerContent component', () => {
129+
renderWithIntl(<GradingPage />);
130+
131+
// Initially should pass 'single'
132+
expect(screen.getByText('Grading Content for: single')).toBeInTheDocument();
133+
expect(screen.queryByText('Grading Content for: all')).not.toBeInTheDocument();
134+
});
135+
});

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: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import { screen } from '@testing-library/react';
2+
import { renderWithIntl } from '@src/testUtils';
3+
import { useGradingConfiguration } from '../data/apiHook';
4+
import GradingConfigurationModal from './GradingConfigurationModal';
5+
import messages from '../messages';
6+
7+
jest.mock('react-router-dom', () => ({
8+
...jest.requireActual('react-router-dom'),
9+
useParams: () => ({
10+
courseId: 'course-v1:edX+DemoX+Demo_Course',
11+
}),
12+
}));
13+
14+
jest.mock('../data/apiHook', () => ({
15+
useGradingConfiguration: jest.fn(),
16+
}));
17+
18+
describe('GradingConfigurationModal', () => {
19+
const mockOnClose = jest.fn();
20+
21+
beforeEach(() => {
22+
(useGradingConfiguration as jest.Mock).mockReturnValue({ data: null });
23+
});
24+
25+
afterEach(() => {
26+
jest.clearAllMocks();
27+
});
28+
29+
it('renders modal when isOpen is true', () => {
30+
renderWithIntl(<GradingConfigurationModal isOpen={true} onClose={mockOnClose} />);
31+
expect(screen.getByRole('dialog')).toBeInTheDocument();
32+
});
33+
34+
it('does not render modal when isOpen is false', () => {
35+
renderWithIntl(<GradingConfigurationModal isOpen={false} onClose={mockOnClose} />);
36+
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
37+
});
38+
39+
it('displays grading configuration data when available', () => {
40+
(useGradingConfiguration as jest.Mock).mockReturnValue({ data: 'Test grading configuration' });
41+
renderWithIntl(<GradingConfigurationModal isOpen={true} onClose={mockOnClose} />);
42+
expect(screen.getByText('Test grading configuration')).toBeInTheDocument();
43+
});
44+
45+
it('displays no grading configuration message when data is null', () => {
46+
(useGradingConfiguration as jest.Mock).mockReturnValue({ data: null });
47+
renderWithIntl(<GradingConfigurationModal isOpen={true} onClose={mockOnClose} />);
48+
expect(screen.getByText(messages.noGradingConfiguration.defaultMessage)).toBeInTheDocument();
49+
});
50+
51+
it('calls useGradingConfiguration with courseId from params', () => {
52+
renderWithIntl(<GradingConfigurationModal isOpen={true} onClose={mockOnClose} />);
53+
expect(useGradingConfiguration).toHaveBeenCalledWith('course-v1:edX+DemoX+Demo_Course');
54+
});
55+
});

0 commit comments

Comments
 (0)