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 51fe1f66..87dd3209 100644 --- a/src/courseInfo/types.ts +++ b/src/courseInfo/types.ts @@ -26,6 +26,8 @@ export interface CourseInfoResponse { dataResearcher: boolean, [key: string]: boolean, }, + gradebookUrl: string, + studioGradingUrl?: string, } interface EnrollmentCounts extends Record { diff --git a/src/data/apiHook.test.tsx b/src/data/apiHook.test.tsx index b4562063..df1ab272 100644 --- a/src/data/apiHook.test.tsx +++ b/src/data/apiHook.test.tsx @@ -33,7 +33,8 @@ const mockCourseData = { permissions: { admin: true, dataResearcher: false, - } + }, + 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..37980e9c --- /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 '@src/grading/GradingPage'; +import messages from '@src/grading/messages'; + +// Mock child components, each component should have its own test suite +jest.mock('@src/grading/components/GradingLearnerContent', () => { + return function MockGradingLearnerContent({ toolType }: { toolType: string }) { + return
Grading Content for: {toolType}
; + }; +}); + +jest.mock('@src/grading/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..289707e8 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 { PendingTasks } from '@src/components/PendingTasks'; +import GradingActionRow from '@src/grading/components/GradingActionRow'; +import GradingLearnerContent from '@src/grading/components/GradingLearnerContent'; +import messages from '@src/grading/messages'; +import { GradingToolsType } from '@src/grading/types'; + 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..0226ec8c --- /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 { useCourseInfo } from '@src/data/apiHook'; +import GradingActionRow from '@src/grading/components/GradingActionRow'; +import { useGradingConfiguration } from '@src/grading/data/apiHook'; +import messages from '@src/grading/messages'; +import { renderWithIntl } from '@src/testUtils'; + +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..88592225 --- /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 GradingConfigurationModal from '@src/grading/components/GradingConfigurationModal'; +import messages from '@src/grading/messages'; + +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..6679c19b --- /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 '@src/grading/data/apiHook'; +import GradingConfigurationModal from '@src/grading/components/GradingConfigurationModal'; +import messages from '@src/grading/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..8abe0985 --- /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 '@src/grading/messages'; +import { useGradingConfiguration } from '@src/grading/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..5b9e518e --- /dev/null +++ b/src/grading/components/GradingLearnerContent.tsx @@ -0,0 +1,27 @@ +import { useIntl } from '@openedx/frontend-base'; +import SpecifyProblem from '@src/components/SpecifyProblem'; +import messages from '@src/grading/messages'; +import { GradingToolsType } from '@src/grading/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.test.ts b/src/grading/data/api.test.ts new file mode 100644 index 00000000..e1a1eab3 --- /dev/null +++ b/src/grading/data/api.test.ts @@ -0,0 +1,48 @@ +import { camelCaseObject, getAppConfig, getAuthenticatedHttpClient } from '@openedx/frontend-base'; +import { getGradingConfiguration } from '@src/grading/data/api'; + +jest.mock('@openedx/frontend-base', () => ({ + ...jest.requireActual('@openedx/frontend-base'), + camelCaseObject: jest.fn((obj) => obj), + getAppConfig: jest.fn(), + getAuthenticatedHttpClient: jest.fn(), +})); + +const mockGetAppConfig = getAppConfig as jest.MockedFunction; +const mockGetAuthenticatedHttpClient = getAuthenticatedHttpClient as jest.MockedFunction; +const mockCamelCaseObject = camelCaseObject as jest.MockedFunction; + +describe('getGradingConfiguration', () => { + const mockHttpClient = { + get: jest.fn(), + }; + const mockConfigData = { grading_policy: 'test_policy' }; + const mockCamelCaseData = { gradingPolicy: 'test_policy' }; + + beforeEach(() => { + mockGetAppConfig.mockReturnValue({ LMS_BASE_URL: 'https://test-lms.com' }); + mockGetAuthenticatedHttpClient.mockReturnValue(mockHttpClient as any); + mockCamelCaseObject.mockReturnValue(mockCamelCaseData); + mockHttpClient.get.mockResolvedValue({ data: mockConfigData }); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + it('fetches grading configuration successfully', async () => { + const courseId = 'test-course-123'; + const result = await getGradingConfiguration(courseId); + expect(mockGetAppConfig).toHaveBeenCalled(); + expect(mockGetAuthenticatedHttpClient).toHaveBeenCalled(); + expect(mockHttpClient.get).toHaveBeenCalledWith('https://test-lms.com/api/instructor/v2/courses/test-course-123/grading-config'); + expect(mockCamelCaseObject).toHaveBeenCalledWith(mockConfigData); + expect(result).toBe(mockCamelCaseData); + }); + + it('throws error when API call fails', async () => { + const error = new Error('Network error'); + mockHttpClient.get.mockRejectedValue(error); + await expect(getGradingConfiguration('test-course')).rejects.toThrow('Network error'); + }); +}); diff --git a/src/grading/data/api.ts b/src/grading/data/api.ts new file mode 100644 index 00000000..9b1262f9 --- /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-config`); + return camelCaseObject(data); +}; diff --git a/src/grading/data/apiHook.test.tsx b/src/grading/data/apiHook.test.tsx new file mode 100644 index 00000000..5572556a --- /dev/null +++ b/src/grading/data/apiHook.test.tsx @@ -0,0 +1,50 @@ +import { renderHook, waitFor } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import * as api from '@src/grading/data/api'; +import { useGradingConfiguration } from '@src/grading/data/apiHook'; + +const createWrapper = () => { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false }, + }, + }); + + const Wrapper = ({ children }: { children: React.ReactNode }) => ( + {children} + ); + + Wrapper.displayName = 'TestWrapper'; + return Wrapper; +}; + +describe('useGradingConfiguration', () => { + const wrapper = createWrapper(); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('returns data when getGradingConfiguration resolves', async () => { + const mockData = { gradingPolicy: 'test_policy' }; + jest.spyOn(api, 'getGradingConfiguration').mockResolvedValueOnce(mockData); + const { result } = renderHook(() => useGradingConfiguration('course-v1:abc123'), { wrapper }); + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(result.current.data).toEqual(mockData); + }); + + it('returns error when getGradingConfiguration rejects', async () => { + jest.spyOn(api, 'getGradingConfiguration').mockRejectedValueOnce(new Error('Network error')); + const { result } = renderHook(() => useGradingConfiguration('course-v1:abc123'), { wrapper }); + await waitFor(() => expect(result.current.isError).toBe(true)); + expect(result.current.error).toBeInstanceOf(Error); + expect(result?.current?.error?.message).toBe('Network error'); + }); + + it('does not run query if courseId is falsy', () => { + const spy = jest.spyOn(api, 'getGradingConfiguration'); + renderHook(() => useGradingConfiguration(''), { wrapper }); + expect(spy).not.toHaveBeenCalled(); + }); +}); diff --git a/src/grading/data/apiHook.ts b/src/grading/data/apiHook.ts new file mode 100644 index 00000000..0ad850c6 --- /dev/null +++ b/src/grading/data/apiHook.ts @@ -0,0 +1,11 @@ +import { useQuery } from '@tanstack/react-query'; +import { getGradingConfiguration } from '@src/grading/data/api'; +import { gradingQueryKeys } from '@src/grading/data/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..9b4ff53d --- /dev/null +++ b/src/grading/data/queryKeys.ts @@ -0,0 +1,7 @@ +import { appId } from '@src/constants'; + +export const gradingQueryKeys = { + all: [appId, 'grading'] 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..279fcd23 --- /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';