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
5 changes: 5 additions & 0 deletions src/components/SpecifyProblem.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
const SpecifyProblem = () => {
return <div>Specify Problem</div>;
};

export default SpecifyProblem;
2 changes: 2 additions & 0 deletions src/courseInfo/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ export interface CourseInfoResponse {
gradeCutoffs: string | null,
staffCount: number,
learnerCount: number,
gradebookUrl: string,
studioGradingUrl?: string,
}

interface EnrollmentCounts extends Record<string, number> {
Expand Down
1 change: 1 addition & 0 deletions src/data/apiHook.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ const mockCourseData = {
gradeCutoffs: null,
staffCount: 5,
learnerCount: 145,
gradebookUrl: 'http://example.com/gradebook',
};

const createWrapper = () => {
Expand Down
135 changes: 135 additions & 0 deletions src/grading/GradingPage.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
import { screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { renderWithIntl } from '@src/testUtils';
import GradingPage from './GradingPage';
import messages from './messages';

// Mock child components, each component should have its own test suite
jest.mock('./components/GradingLearnerContent', () => {
return function MockGradingLearnerContent({ toolType }: { toolType: string }) {
return <div>Grading Content for: {toolType}</div>;
};
});

jest.mock('./components/GradingActionRow', () => {
return function MockGradingActionRow() {
return <div>Grading Action Row</div>;
};
});

jest.mock('@src/components/PendingTasks', () => {
return {
PendingTasks: function MockPendingTasks() {
return <div>Pending Tasks</div>;
}
};
});

describe('GradingPage', () => {
beforeEach(() => {
jest.clearAllMocks();
});

it('renders the page title correctly', () => {
renderWithIntl(<GradingPage />);
expect(screen.getByText(messages.pageTitle.defaultMessage)).toBeInTheDocument();
});

it('renders all child components', () => {
renderWithIntl(<GradingPage />);
expect(screen.getByText('Grading Action Row')).toBeInTheDocument();
expect(screen.getByText('Grading Content for: single')).toBeInTheDocument();
expect(screen.getByText('Pending Tasks')).toBeInTheDocument();
});

it('renders both button options with correct labels', () => {
renderWithIntl(<GradingPage />);
expect(screen.getByRole('button', { name: messages.singleLearner.defaultMessage })).toBeInTheDocument();
expect(screen.getByRole('button', { name: messages.allLearners.defaultMessage })).toBeInTheDocument();
});

it('defaults to single learner tool being selected', () => {
renderWithIntl(<GradingPage />);
const singleLearnerButton = screen.getByRole('button', { name: messages.singleLearner.defaultMessage });
const allLearnersButton = screen.getByRole('button', { name: messages.allLearners.defaultMessage });

// Single learner should have primary variant (selected state)
expect(singleLearnerButton).toHaveClass('btn-primary');
expect(allLearnersButton).toHaveClass('btn-outline-primary');

// GradingLearnerContent should receive 'single' as initial toolType
expect(screen.getByText('Grading Content for: single')).toBeInTheDocument();
});

it('switches to All Learners when All Learners button is clicked', async () => {
renderWithIntl(<GradingPage />);
const user = userEvent.setup();

const allLearnersButton = screen.getByRole('button', { name: messages.allLearners.defaultMessage });

await user.click(allLearnersButton);

// All learners should now be selected
expect(allLearnersButton).toHaveClass('btn-primary');
expect(screen.getByRole('button', { name: messages.singleLearner.defaultMessage })).toHaveClass('btn-outline-primary');

// GradingLearnerContent should receive 'all' as toolType
expect(screen.getByText('Grading Content for: all')).toBeInTheDocument();
});

it('switches back to Single Learner when Single Learner button is clicked', async () => {
renderWithIntl(<GradingPage />);
const user = userEvent.setup();

const singleLearnerButton = screen.getByRole('button', { name: messages.singleLearner.defaultMessage });
const allLearnersButton = screen.getByRole('button', { name: messages.allLearners.defaultMessage });

// First switch to all learners
await user.click(allLearnersButton);
expect(screen.getByText('Grading Content for: all')).toBeInTheDocument();

// Then switch back to single learner
await user.click(singleLearnerButton);

// Single learner should be selected again
expect(singleLearnerButton).toHaveClass('btn-primary');
expect(allLearnersButton).toHaveClass('btn-outline-primary');
expect(screen.getByText('Grading Content for: single')).toBeInTheDocument();
});

it('maintains correct button states during multiple interactions', async () => {
renderWithIntl(<GradingPage />);
const user = userEvent.setup();

const singleLearnerButton = screen.getByRole('button', { name: messages.singleLearner.defaultMessage });
const allLearnersButton = screen.getByRole('button', { name: messages.allLearners.defaultMessage });

// Initial state - single learner selected
expect(singleLearnerButton).toHaveClass('btn-primary');
expect(allLearnersButton).toHaveClass('btn-outline-primary');

// Click all learners multiple times - should remain selected
await user.click(allLearnersButton);
await user.click(allLearnersButton);

expect(allLearnersButton).toHaveClass('btn-primary');
expect(singleLearnerButton).toHaveClass('btn-outline-primary');
expect(screen.getByText('Grading Content for: all')).toBeInTheDocument();

// Click single learner multiple times - should remain selected
await user.click(singleLearnerButton);
await user.click(singleLearnerButton);

expect(singleLearnerButton).toHaveClass('btn-primary');
expect(allLearnersButton).toHaveClass('btn-outline-primary');
expect(screen.getByText('Grading Content for: single')).toBeInTheDocument();
});

it('passes correct toolType prop to GradingLearnerContent component', () => {
renderWithIntl(<GradingPage />);

// Initially should pass 'single'
expect(screen.getByText('Grading Content for: single')).toBeInTheDocument();
expect(screen.queryByText('Grading Content for: all')).not.toBeInTheDocument();
});
});
39 changes: 36 additions & 3 deletions src/grading/GradingPage.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,41 @@
import { useState } from 'react';
import { useIntl } from '@openedx/frontend-base';
import { Button, ButtonGroup, Card } from '@openedx/paragon';
import GradingLearnerContent from './components/GradingLearnerContent';
import messages from './messages';
import GradingActionRow from './components/GradingActionRow';
import { GradingToolsType } from './types';
import { PendingTasks } from '@src/components/PendingTasks';

const GradingPage = () => {
const intl = useIntl();
const [selectedTools, setSelectedTools] = useState<GradingToolsType>('single');

return (
<div>
<h3>Grading</h3>
</div>
<>
<div className="d-flex justify-content-between align-items-center">
<h3 className="text-primary-700">{intl.formatMessage(messages.pageTitle)}</h3>
<GradingActionRow />
</div>
<Card className="bg-light-200 p-4 mt-4.5">
<ButtonGroup className="d-block">
<Button
onClick={() => setSelectedTools('single')}
variant={selectedTools === 'single' ? 'primary' : 'outline-primary'}
>
{intl.formatMessage(messages.singleLearner)}
</Button>
<Button
onClick={() => setSelectedTools('all')}
variant={selectedTools === 'all' ? 'primary' : 'outline-primary'}
>
{intl.formatMessage(messages.allLearners)}
</Button>
</ButtonGroup>
<GradingLearnerContent toolType={selectedTools} />
</Card>
<PendingTasks />
</>
);
};

Expand Down
58 changes: 58 additions & 0 deletions src/grading/components/GradingActionRow.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { renderWithIntl } from '@src/testUtils';
import GradingActionRow from '@src/grading/components/GradingActionRow';
import messages from '../messages';
import { useCourseInfo } from '@src/data/apiHook';
import { useGradingConfiguration } from '../data/apiHook';

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

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

jest.mock('@src/grading/data/apiHook', () => ({
useGradingConfiguration: jest.fn(),
}));

describe('GradingActionRow', () => {
beforeEach(() => {
jest.clearAllMocks();
(useCourseInfo as jest.Mock).mockReturnValue({ data: { gradebookUrl: 'https://example.com/gradebook', studioGradingUrl: 'https://example.com/studio' } });
// TODO: Update this mock to use similar structure when API is ready, currently just returning random text to ensure component renders without error
(useGradingConfiguration as jest.Mock).mockReturnValue({ data: 'Some random text' });
});

it('renders ActionRow with gradebook and configuration buttons', () => {
renderWithIntl(<GradingActionRow />);
expect(screen.getByRole('link', { name: messages.viewGradebook.defaultMessage })).toBeInTheDocument();
expect(screen.getByRole('button', { name: messages.configurationAlt.defaultMessage })).toBeInTheDocument();
});

it('opens configuration menu when configuration button is clicked', async () => {
renderWithIntl(<GradingActionRow />);
const user = userEvent.setup();
await user.click(screen.getByRole('button', { name: messages.configurationAlt.defaultMessage }));
expect(screen.getByText('View Grading Configuration')).toBeInTheDocument();
expect(screen.getByText('View Course Grading Settings')).toBeInTheDocument();
});

it('opens and closes GradingConfigurationModal when menu item is clicked', async () => {
renderWithIntl(<GradingActionRow />);
const user = userEvent.setup();
await user.click(screen.getByRole('button', { name: messages.configurationAlt.defaultMessage }));
const gradingConfigButton = screen.getByText('View Grading Configuration');
await user.click(gradingConfigButton);
expect(screen.getByRole('dialog', { name: messages.gradingConfiguration.defaultMessage })).toBeInTheDocument();

// Close modal
await user.click(screen.getAllByRole('button', { name: messages.close.defaultMessage })[0]);
expect(screen.queryByRole('dialog', { name: messages.gradingConfiguration.defaultMessage })).not.toBeInTheDocument();
});
});
54 changes: 54 additions & 0 deletions src/grading/components/GradingActionRow.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { useState } from 'react';
import { useParams } from 'react-router-dom';
import { useIntl } from '@openedx/frontend-base';
import { useToggle, ActionRow, Button, IconButton, ModalPopup, Menu, MenuItem } from '@openedx/paragon';
import { TrendingUp, MoreVert, OpenInNew } from '@openedx/paragon/icons';
import { useCourseInfo } from '@src/data/apiHook';
import messages from '../messages';
import GradingConfigurationModal from './GradingConfigurationModal';

const GradingActionRow = () => {
const { courseId = '' } = useParams<{ courseId: string }>();
const intl = useIntl();
const { data = { gradebookUrl: '', studioGradingUrl: '' } } = useCourseInfo(courseId);
const [configurationMenuTarget, setConfigurationMenuTarget] = useState<HTMLButtonElement | null>(null);
const [isOpenMenu, openMenu, closeMenu] = useToggle(false);
const [isOpenConfigModal, openConfigModal, closeConfigModal] = useToggle(false);

const handleConfigurationMenuClick = (event: React.MouseEvent<HTMLButtonElement>) => {
setConfigurationMenuTarget(event?.currentTarget);
openMenu();
};

const handleConfigModalOpen = () => {
openConfigModal();
closeMenu();
};

return (
<>
<ActionRow>
<Button as="a" href={data.gradebookUrl} iconBefore={TrendingUp} variant="outline-primary">{intl.formatMessage(messages.viewGradebook)}</Button>
<IconButton
alt={intl.formatMessage(messages.configurationAlt)}
className="lead"
iconAs={MoreVert}
onClick={handleConfigurationMenuClick}
/>
</ActionRow>
<ModalPopup positionRef={configurationMenuTarget} onClose={closeMenu} isOpen={isOpenMenu}>
<Menu>
<MenuItem onClick={handleConfigModalOpen}>
{intl.formatMessage(messages.viewGradingConfiguration)}
</MenuItem>
<MenuItem iconAfter={OpenInNew} as="a" href={data.studioGradingUrl} target="_blank">
{intl.formatMessage(messages.viewCourseGradingSettings)}
</MenuItem>
</Menu>
</ModalPopup>
<GradingConfigurationModal isOpen={isOpenConfigModal} onClose={closeConfigModal} />
</>
);
};

export default GradingActionRow;
55 changes: 55 additions & 0 deletions src/grading/components/GradingConfigurationModal.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { screen } from '@testing-library/react';
import { renderWithIntl } from '@src/testUtils';
import { useGradingConfiguration } from '../data/apiHook';
import GradingConfigurationModal from './GradingConfigurationModal';
import messages from '../messages';

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

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

describe('GradingConfigurationModal', () => {
const mockOnClose = jest.fn();

beforeEach(() => {
(useGradingConfiguration as jest.Mock).mockReturnValue({ data: null });
});

afterEach(() => {
jest.clearAllMocks();
});

it('renders modal when isOpen is true', () => {
renderWithIntl(<GradingConfigurationModal isOpen={true} onClose={mockOnClose} />);
expect(screen.getByRole('dialog')).toBeInTheDocument();
});

it('does not render modal when isOpen is false', () => {
renderWithIntl(<GradingConfigurationModal isOpen={false} onClose={mockOnClose} />);
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
});

it('displays grading configuration data when available', () => {
(useGradingConfiguration as jest.Mock).mockReturnValue({ data: 'Test grading configuration' });
renderWithIntl(<GradingConfigurationModal isOpen={true} onClose={mockOnClose} />);
expect(screen.getByText('Test grading configuration')).toBeInTheDocument();
});

it('displays no grading configuration message when data is null', () => {
(useGradingConfiguration as jest.Mock).mockReturnValue({ data: null });
renderWithIntl(<GradingConfigurationModal isOpen={true} onClose={mockOnClose} />);
expect(screen.getByText(messages.noGradingConfiguration.defaultMessage)).toBeInTheDocument();
});

it('calls useGradingConfiguration with courseId from params', () => {
renderWithIntl(<GradingConfigurationModal isOpen={true} onClose={mockOnClose} />);
expect(useGradingConfiguration).toHaveBeenCalledWith('course-v1:edX+DemoX+Demo_Course');
});
});
Loading