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 ( + <> + + + + + + + + {intl.formatMessage(messages.viewGradingConfiguration)} + + + {intl.formatMessage(messages.viewCourseGradingSettings)} + + + + + + ); +}; + +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';