diff --git a/src/components/SpecifyProblem.tsx b/src/components/SpecifyProblem.tsx
new file mode 100644
index 00000000..ef4b1433
--- /dev/null
+++ b/src/components/SpecifyProblem.tsx
@@ -0,0 +1,5 @@
+const SpecifyProblem = () => {
+ return
Specify Problem
;
+};
+
+export default SpecifyProblem;
diff --git a/src/courseInfo/types.ts b/src/courseInfo/types.ts
index b888d892..4488fa5e 100644
--- a/src/courseInfo/types.ts
+++ b/src/courseInfo/types.ts
@@ -21,6 +21,8 @@ export interface CourseInfoResponse {
gradeCutoffs: string | null,
staffCount: number,
learnerCount: number,
+ gradebookUrl: string,
+ studioGradingUrl?: string,
}
interface EnrollmentCounts extends Record {
diff --git a/src/data/apiHook.test.tsx b/src/data/apiHook.test.tsx
index b4813dbf..696c4c56 100644
--- a/src/data/apiHook.test.tsx
+++ b/src/data/apiHook.test.tsx
@@ -29,6 +29,7 @@ const mockCourseData = {
gradeCutoffs: null,
staffCount: 5,
learnerCount: 145,
+ gradebookUrl: 'http://example.com/gradebook',
};
const createWrapper = () => {
diff --git a/src/grading/GradingPage.test.tsx b/src/grading/GradingPage.test.tsx
new file mode 100644
index 00000000..0f597f12
--- /dev/null
+++ b/src/grading/GradingPage.test.tsx
@@ -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 Grading Content for: {toolType}
;
+ };
+});
+
+jest.mock('./components/GradingActionRow', () => {
+ return function MockGradingActionRow() {
+ return Grading Action Row
;
+ };
+});
+
+jest.mock('@src/components/PendingTasks', () => {
+ return {
+ PendingTasks: function MockPendingTasks() {
+ return Pending Tasks
;
+ }
+ };
+});
+
+describe('GradingPage', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('renders the page title correctly', () => {
+ renderWithIntl();
+ expect(screen.getByText(messages.pageTitle.defaultMessage)).toBeInTheDocument();
+ });
+
+ it('renders all child components', () => {
+ renderWithIntl();
+ 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();
+ 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();
+ 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();
+ 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();
+ 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();
+ 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();
+
+ // Initially should pass 'single'
+ expect(screen.getByText('Grading Content for: single')).toBeInTheDocument();
+ expect(screen.queryByText('Grading Content for: all')).not.toBeInTheDocument();
+ });
+});
diff --git a/src/grading/GradingPage.tsx b/src/grading/GradingPage.tsx
index e00ff9bf..b1821818 100644
--- a/src/grading/GradingPage.tsx
+++ b/src/grading/GradingPage.tsx
@@ -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('single');
+
return (
-
-
Grading
-
+ <>
+
+
{intl.formatMessage(messages.pageTitle)}
+
+
+
+
+
+
+
+
+
+
+ >
);
};
diff --git a/src/grading/components/GradingActionRow.test.tsx b/src/grading/components/GradingActionRow.test.tsx
new file mode 100644
index 00000000..002ce7b3
--- /dev/null
+++ b/src/grading/components/GradingActionRow.test.tsx
@@ -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();
+ 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();
+ 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();
+ 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();
+ });
+});
diff --git a/src/grading/components/GradingActionRow.tsx b/src/grading/components/GradingActionRow.tsx
new file mode 100644
index 00000000..d9608065
--- /dev/null
+++ b/src/grading/components/GradingActionRow.tsx
@@ -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(null);
+ const [isOpenMenu, openMenu, closeMenu] = useToggle(false);
+ const [isOpenConfigModal, openConfigModal, closeConfigModal] = useToggle(false);
+
+ const handleConfigurationMenuClick = (event: React.MouseEvent) => {
+ setConfigurationMenuTarget(event?.currentTarget);
+ openMenu();
+ };
+
+ const handleConfigModalOpen = () => {
+ openConfigModal();
+ closeMenu();
+ };
+
+ return (
+ <>
+
+
+
+
+
+
+
+
+ >
+ );
+};
+
+export default GradingActionRow;
diff --git a/src/grading/components/GradingConfigurationModal.test.tsx b/src/grading/components/GradingConfigurationModal.test.tsx
new file mode 100644
index 00000000..81c9fe53
--- /dev/null
+++ b/src/grading/components/GradingConfigurationModal.test.tsx
@@ -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();
+ expect(screen.getByRole('dialog')).toBeInTheDocument();
+ });
+
+ it('does not render modal when isOpen is false', () => {
+ renderWithIntl();
+ expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
+ });
+
+ it('displays grading configuration data when available', () => {
+ (useGradingConfiguration as jest.Mock).mockReturnValue({ data: 'Test grading configuration' });
+ renderWithIntl();
+ 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();
+ expect(screen.getByText(messages.noGradingConfiguration.defaultMessage)).toBeInTheDocument();
+ });
+
+ it('calls useGradingConfiguration with courseId from params', () => {
+ renderWithIntl();
+ expect(useGradingConfiguration).toHaveBeenCalledWith('course-v1:edX+DemoX+Demo_Course');
+ });
+});
diff --git a/src/grading/components/GradingConfigurationModal.tsx b/src/grading/components/GradingConfigurationModal.tsx
new file mode 100644
index 00000000..a559eb2a
--- /dev/null
+++ b/src/grading/components/GradingConfigurationModal.tsx
@@ -0,0 +1,32 @@
+import { useParams } from 'react-router-dom';
+import { Button, ModalDialog } from '@openedx/paragon';
+import { useIntl } from '@openedx/frontend-base';
+import messages from '../messages';
+import { useGradingConfiguration } from '../data/apiHook';
+
+interface GradingConfigurationModalProps {
+ isOpen: boolean,
+ onClose: () => void,
+}
+
+const GradingConfigurationModal = ({ isOpen, onClose }: GradingConfigurationModalProps) => {
+ const intl = useIntl();
+ const { courseId = '' } = useParams<{ courseId: string }>();
+ const { data = null } = useGradingConfiguration(courseId);
+
+ return (
+
+
+ {intl.formatMessage(messages.gradingConfiguration)}
+
+
+ {data ?? intl.formatMessage(messages.noGradingConfiguration)}
+
+
+
+
+
+ );
+};
+
+export default GradingConfigurationModal;
diff --git a/src/grading/components/GradingLearnerContent.tsx b/src/grading/components/GradingLearnerContent.tsx
new file mode 100644
index 00000000..f3d00616
--- /dev/null
+++ b/src/grading/components/GradingLearnerContent.tsx
@@ -0,0 +1,27 @@
+import { useIntl } from '@openedx/frontend-base';
+import messages from '../messages';
+import SpecifyProblem from '../../components/SpecifyProblem';
+import { GradingToolsType } from '../types';
+
+interface GradingLearnerContentProps {
+ toolType: GradingToolsType,
+}
+
+const GradingLearnerContent = ({ toolType }: GradingLearnerContentProps) => {
+ const intl = useIntl();
+
+ return (
+ <>
+
+ {
+ toolType === 'single'
+ ? intl.formatMessage(messages.descriptionSingleLearner)
+ : intl.formatMessage(messages.descriptionAllLearners)
+ }
+
+
+ >
+ );
+};
+
+export default GradingLearnerContent;
diff --git a/src/grading/data/api.ts b/src/grading/data/api.ts
new file mode 100644
index 00000000..6ed04180
--- /dev/null
+++ b/src/grading/data/api.ts
@@ -0,0 +1,8 @@
+import { camelCaseObject, getAuthenticatedHttpClient } from '@openedx/frontend-base';
+import { getApiBaseUrl } from '@src/data/api';
+
+export const getGradingConfiguration = async (courseId: string) => {
+ const { data } = await getAuthenticatedHttpClient().get(`${getApiBaseUrl()}/api/instructor/v2/courses/${courseId}/grading_configuration`
+ );
+ return camelCaseObject(data);
+};
diff --git a/src/grading/data/apiHook.ts b/src/grading/data/apiHook.ts
new file mode 100644
index 00000000..68443b19
--- /dev/null
+++ b/src/grading/data/apiHook.ts
@@ -0,0 +1,11 @@
+import { useQuery } from '@tanstack/react-query';
+import { getGradingConfiguration } from './api';
+import { gradingQueryKeys } from './queryKeys';
+
+export const useGradingConfiguration = (courseId: string) => (
+ useQuery({
+ queryKey: gradingQueryKeys.gradingConfiguration(courseId),
+ queryFn: () => getGradingConfiguration(courseId),
+ enabled: !!courseId,
+ })
+);
diff --git a/src/grading/data/queryKeys.ts b/src/grading/data/queryKeys.ts
new file mode 100644
index 00000000..1d73cb39
--- /dev/null
+++ b/src/grading/data/queryKeys.ts
@@ -0,0 +1,7 @@
+import { appId } from '@src/constants';
+
+export const gradingQueryKeys = {
+ all: [appId, 'dateExtensions'] as const,
+ byCourse: (courseId: string) => [...gradingQueryKeys.all, courseId] as const,
+ gradingConfiguration: (courseId: string) => [...gradingQueryKeys.byCourse(courseId), 'gradingConfiguration'] as const,
+};
diff --git a/src/grading/messages.ts b/src/grading/messages.ts
new file mode 100644
index 00000000..42945e58
--- /dev/null
+++ b/src/grading/messages.ts
@@ -0,0 +1,66 @@
+import { defineMessages } from '@openedx/frontend-base';
+
+const messages = defineMessages({
+ pageTitle: {
+ id: 'instruct.grading.pageTitle',
+ defaultMessage: 'Grading Tools',
+ description: 'Title for the grading page'
+ },
+ configurationAlt: {
+ id: 'instruct.grading.configurationAlt',
+ defaultMessage: 'Grading Configuration and Settings',
+ description: 'Alt text for the configuration icon button'
+ },
+ viewGradebook: {
+ id: 'instruct.grading.viewGradebook',
+ defaultMessage: 'View Gradebook',
+ description: 'Text for the button to view the gradebook'
+ },
+ singleLearner: {
+ id: 'instruct.grading.singleLearner',
+ defaultMessage: 'Single Learner',
+ description: 'Single Learner button label to display corresponding grading tools'
+ },
+ allLearners: {
+ id: 'instruct.grading.allLearners',
+ defaultMessage: 'All Learners',
+ description: 'All learners button label to display corresponding grading tools'
+ },
+ descriptionSingleLearner: {
+ id: 'instruct.grading.descriptionSingleLearner',
+ defaultMessage: 'These grading tools allow for grade review and adjustment for a specific learner on a specific problem.',
+ description: 'Description for single learner grading tools'
+ },
+ descriptionAllLearners: {
+ id: 'instruct.grading.descriptionAllLearners',
+ defaultMessage: 'These grading tools allow for grade review and adjustment all enrolled learners on a specific problem. ',
+ description: 'Description for all learners grading tools'
+ },
+ gradingConfiguration: {
+ id: 'instruct.grading.gradingConfiguration',
+ defaultMessage: 'Grading Configuration',
+ description: 'Title for the grading configuration modal'
+ },
+ close: {
+ id: 'instruct.grading.modals.close',
+ defaultMessage: 'Close',
+ description: 'Text for the close button in the grading configuration modal'
+ },
+ viewGradingConfiguration: {
+ id: 'instruct.grading.viewGradingConfiguration',
+ defaultMessage: 'View Grading Configuration',
+ description: 'View grading configuration menu item label'
+ },
+ viewCourseGradingSettings: {
+ id: 'instruct.grading.viewCourseGradingSettings',
+ defaultMessage: 'View Course Grading Settings',
+ description: 'View course grading settings menu item label'
+ },
+ noGradingConfiguration: {
+ id: 'instruct.grading.noGradingConfiguration',
+ defaultMessage: 'No grading configuration found for this course.',
+ description: 'Message to display when there is no grading configuration for the course'
+ }
+});
+
+export default messages;
diff --git a/src/grading/types.ts b/src/grading/types.ts
new file mode 100644
index 00000000..7dd9b2de
--- /dev/null
+++ b/src/grading/types.ts
@@ -0,0 +1 @@
+export type GradingToolsType = 'single' | 'all';