From 5bdef7cffaa4f27ab558205b035b5b9c53109553 Mon Sep 17 00:00:00 2001 From: Victor Navarro Date: Mon, 11 Aug 2025 14:24:38 -0600 Subject: [PATCH] fix: disable special exams config if feature flag is disabled (#2325) * fix: disable special exams config if feature flag is disabled * test: add testcases * fix: convert AdvancedTab to typescript --- src/course-outline/CourseOutline.tsx | 8 +- src/course-outline/data/selectors.test.js | 41 ++ src/course-outline/data/selectors.ts | 1 + src/course-outline/data/slice.test.js | 79 +++ src/course-outline/data/slice.ts | 2 + src/course-outline/data/types.ts | 1 + .../configure-modal/AdvancedTab.test.jsx | 615 ++++++++++++++++++ .../{AdvancedTab.jsx => AdvancedTab.tsx} | 169 +++-- .../configure-modal/ConfigureModal.jsx | 5 +- .../configure-modal/ConfigureModal.test.jsx | 128 ++++ src/generic/configure-modal/messages.js | 4 + 11 files changed, 986 insertions(+), 67 deletions(-) create mode 100644 src/course-outline/data/selectors.test.js create mode 100644 src/course-outline/data/slice.test.js create mode 100644 src/generic/configure-modal/AdvancedTab.test.jsx rename src/generic/configure-modal/{AdvancedTab.jsx => AdvancedTab.tsx} (65%) diff --git a/src/course-outline/CourseOutline.tsx b/src/course-outline/CourseOutline.tsx index af1dd80de..0aec6cb48 100644 --- a/src/course-outline/CourseOutline.tsx +++ b/src/course-outline/CourseOutline.tsx @@ -36,7 +36,11 @@ import { ContentType } from '@src/library-authoring/routes'; import { NOTIFICATION_MESSAGES } from '@src/constants'; import { COMPONENT_TYPES } from '@src/generic/block-type-utils/constants'; import { XBlock } from '@src/data/types'; -import { getCurrentItem, getProctoredExamsFlag } from './data/selectors'; +import { + getCurrentItem, + getProctoredExamsFlag, + getTimedExamsFlag, +} from './data/selectors'; import { COURSE_BLOCK_NAMES } from './constants'; import StatusBar from './status-bar/StatusBar'; import EnableHighlightsModal from './enable-highlights-modal/EnableHighlightsModal'; @@ -167,6 +171,7 @@ const CourseOutline = ({ courseId }: CourseOutlineProps) => { const deleteCategory = COURSE_BLOCK_NAMES[currentItemData.category]?.name.toLowerCase(); const enableProctoredExams = useSelector(getProctoredExamsFlag); + const enableTimedExams = useSelector(getTimedExamsFlag); /** * Move section to new index @@ -505,6 +510,7 @@ const CourseOutline = ({ courseId }: CourseOutlineProps) => { onConfigureSubmit={handleConfigureItemSubmit} currentItemData={currentItemData} enableProctoredExams={enableProctoredExams} + enableTimedExams={enableTimedExams} isSelfPaced={statusBarData.isSelfPaced} /> { + describe('getTimedExamsFlag', () => { + it('returns enableTimedExams value from state', () => { + expect(getTimedExamsFlag(mockState)).toBe(true); + }); + + it('returns false when enableTimedExams is false', () => { + const stateWithDisabledExams = { + courseOutline: { + ...mockState.courseOutline, + enableTimedExams: false, + }, + }; + expect(getTimedExamsFlag(stateWithDisabledExams)).toBe(false); + }); + + it('returns undefined when enableTimedExams is not set', () => { + const stateWithoutProperty = { + courseOutline: { + enableProctoredExams: false, + }, + }; + expect(getTimedExamsFlag(stateWithoutProperty)).toBeUndefined(); + }); + }); + + describe('getProctoredExamsFlag', () => { + it('returns enableProctoredExams value from state', () => { + expect(getProctoredExamsFlag(mockState)).toBe(false); + }); + }); +}); diff --git a/src/course-outline/data/selectors.ts b/src/course-outline/data/selectors.ts index 4768d9959..587badfcc 100644 --- a/src/course-outline/data/selectors.ts +++ b/src/course-outline/data/selectors.ts @@ -9,6 +9,7 @@ export const getCurrentSubsection = (state) => state.courseOutline.currentSubsec export const getCourseActions = (state) => state.courseOutline.actions; export const getCustomRelativeDatesActiveFlag = (state) => state.courseOutline.isCustomRelativeDatesActive; export const getProctoredExamsFlag = (state) => state.courseOutline.enableProctoredExams; +export const getTimedExamsFlag = (state) => state.courseOutline.enableTimedExams; export const getPasteFileNotices = (state) => state.courseOutline.pasteFileNotices; export const getErrors = (state) => state.courseOutline.errors; export const getCreatedOn = (state) => state.courseOutline.createdOn; diff --git a/src/course-outline/data/slice.test.js b/src/course-outline/data/slice.test.js new file mode 100644 index 000000000..fc5eb8db5 --- /dev/null +++ b/src/course-outline/data/slice.test.js @@ -0,0 +1,79 @@ +import { configureStore } from '@reduxjs/toolkit'; +import { reducer, fetchOutlineIndexSuccess } from './slice'; + +describe('course-outline slice', () => { + let store; + + beforeEach(() => { + store = configureStore({ + reducer: { + courseOutline: reducer, + }, + }); + }); + + describe('fetchOutlineIndexSuccess action', () => { + it('sets enableTimedExams from payload', () => { + const mockPayload = { + courseStructure: { + enableProctoredExams: true, + enableTimedExams: false, + childInfo: { + children: [], + }, + }, + isCustomRelativeDatesActive: false, + createdOn: null, + }; + + store.dispatch(fetchOutlineIndexSuccess(mockPayload)); + + const state = store.getState(); + expect(state.courseOutline.enableTimedExams).toBe(false); + expect(state.courseOutline.enableProctoredExams).toBe(true); + }); + + it('sets enableTimedExams to true when provided', () => { + const mockPayload = { + courseStructure: { + enableProctoredExams: false, + enableTimedExams: true, + childInfo: { + children: [], + }, + }, + isCustomRelativeDatesActive: false, + createdOn: null, + }; + + store.dispatch(fetchOutlineIndexSuccess(mockPayload)); + + const state = store.getState(); + expect(state.courseOutline.enableTimedExams).toBe(true); + }); + + it('handles missing enableTimedExams field gracefully', () => { + const mockPayload = { + courseStructure: { + enableProctoredExams: true, + childInfo: { + children: [], + }, + }, + isCustomRelativeDatesActive: false, + createdOn: null, + }; + + store.dispatch(fetchOutlineIndexSuccess(mockPayload)); + + const state = store.getState(); + expect(state.courseOutline.enableTimedExams).toBeUndefined(); + expect(state.courseOutline.enableProctoredExams).toBe(true); + }); + + it('initializes with enableTimedExams false by default', () => { + const state = store.getState(); + expect(state.courseOutline.enableTimedExams).toBe(false); + }); + }); +}); diff --git a/src/course-outline/data/slice.ts b/src/course-outline/data/slice.ts index 9e4b659e1..9e504ce50 100644 --- a/src/course-outline/data/slice.ts +++ b/src/course-outline/data/slice.ts @@ -47,6 +47,7 @@ const initialState = { allowMoveDown: false, }, enableProctoredExams: false, + enableTimedExams: false, pasteFileNotices: {}, createdOn: null, } satisfies CourseOutlineState as unknown as CourseOutlineState; @@ -60,6 +61,7 @@ const slice = createSlice({ state.sectionsList = payload.courseStructure?.childInfo?.children || []; state.isCustomRelativeDatesActive = payload.isCustomRelativeDatesActive; state.enableProctoredExams = payload.courseStructure?.enableProctoredExams; + state.enableTimedExams = payload.courseStructure?.enableTimedExams; state.createdOn = payload.createdOn; }, updateOutlineIndexLoadingStatus: (state: CourseOutlineState, { payload }) => { diff --git a/src/course-outline/data/types.ts b/src/course-outline/data/types.ts index a2f2f267d..7937a45cf 100644 --- a/src/course-outline/data/types.ts +++ b/src/course-outline/data/types.ts @@ -59,6 +59,7 @@ export interface CourseOutlineState { currentItem: XBlock | {}; actions: XBlockActions; enableProctoredExams: boolean; + enableTimedExams: boolean; pasteFileNotices: object; createdOn: null | Date; } diff --git a/src/generic/configure-modal/AdvancedTab.test.jsx b/src/generic/configure-modal/AdvancedTab.test.jsx new file mode 100644 index 000000000..17cffeeaf --- /dev/null +++ b/src/generic/configure-modal/AdvancedTab.test.jsx @@ -0,0 +1,615 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { IntlProvider } from '@edx/frontend-platform/i18n'; +import { Formik } from 'formik'; + +import AdvancedTab from './AdvancedTab'; +import messages from './messages'; + +const defaultProps = { + values: { + isTimeLimited: false, + defaultTimeLimitMinutes: 30, + isPrereq: false, + prereqUsageKey: '', + prereqMinScore: 100, + prereqMinCompletion: 100, + isProctoredExam: false, + isPracticeExam: false, + isOnboardingExam: false, + examReviewRules: '', + }, + setFieldValue: jest.fn(), + prereqs: [], + releasedToStudents: false, + wasExamEverLinkedWithExternal: false, + enableProctoredExams: false, + enableTimedExams: true, + supportsOnboarding: false, + wasProctoredExam: false, + showReviewRules: false, + onlineProctoringRules: '', +}; + +const renderComponent = (props = {}) => render( + + {}}> + + + , +); + +describe(' with enableTimedExams prop', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('when enableTimedExams is true', () => { + it('renders special exam options enabled', () => { + renderComponent(); + + expect(screen.getByText('Set as a special exam')).toBeInTheDocument(); + const headingContainer = screen.getByText( + 'Set as a special exam', + ).parentElement; + expect(headingContainer.querySelector('svg')).not.toBeInTheDocument(); + + const noneRadio = screen.getByLabelText('None'); + const timedRadio = screen.getByLabelText('Timed'); + + expect(noneRadio).toBeInTheDocument(); + expect(noneRadio).not.toBeDisabled(); + expect(timedRadio).toBeInTheDocument(); + expect(timedRadio).not.toBeDisabled(); + }); + + it('allows user to interact with timed exam option', async () => { + const user = userEvent.setup(); + renderComponent(); + + const timedRadio = screen.getByLabelText('Timed'); + + expect(timedRadio).not.toBeDisabled(); + await user.click(timedRadio); + + expect(timedRadio).toBeInTheDocument(); + }); + }); + + describe('when enableTimedExams is false', () => { + const disabledProps = { enableTimedExams: false }; + + it('renders special exam options disabled', () => { + renderComponent(disabledProps); + + const noneRadio = screen.getByLabelText('None'); + const timedRadio = screen.getByLabelText('Timed'); + + expect(noneRadio).toBeDisabled(); + expect(timedRadio).toBeDisabled(); + }); + + it('shows tooltip icon next to heading', () => { + renderComponent(disabledProps); + + const questionIcon = screen + .getByText('Set as a special exam') + .parentElement.querySelector('svg'); + expect(questionIcon).toBeInTheDocument(); + expect(questionIcon).toHaveClass('ml-2', 'text-gray-500'); + }); + + it('shows tooltip message when hovering over icon', async () => { + const user = userEvent.setup(); + renderComponent(disabledProps); + + const questionIcon = screen + .getByText('Set as a special exam') + .parentElement.querySelector('svg'); + await user.hover(questionIcon); + + expect( + await screen.findByText( + messages.timedExamsDisabledTooltip.defaultMessage, + ), + ).toBeInTheDocument(); + }); + + it('prevents user from interacting with disabled options', async () => { + const user = userEvent.setup(); + renderComponent(disabledProps); + + const noneRadio = screen.getByLabelText('None'); + const timedRadio = screen.getByLabelText('Timed'); + + expect(noneRadio).toBeDisabled(); + expect(timedRadio).toBeDisabled(); + + await user.click(noneRadio); + await user.click(timedRadio); + + expect(noneRadio).toBeDisabled(); + expect(timedRadio).toBeDisabled(); + }); + + it('does not show tooltip icon when enableTimedExams is true', () => { + renderComponent({ enableTimedExams: true }); + + const headingContainer = screen.getByText( + 'Set as a special exam', + ).parentElement; + expect(headingContainer.querySelector('svg')).not.toBeInTheDocument(); + }); + + it('shows tooltip icon with proper classes', () => { + renderComponent(disabledProps); + + const questionIcon = screen + .getByText('Set as a special exam') + .parentElement.querySelector('svg'); + expect(questionIcon).toHaveClass('ml-2', 'text-gray-500'); + }); + }); + + describe('with proctored exams enabled', () => { + it('shows proctored exam options when both flags are enabled', () => { + renderComponent({ + enableProctoredExams: true, + enableTimedExams: true, + }); + + expect(screen.getByLabelText('None')).not.toBeDisabled(); + expect(screen.getByLabelText('Timed')).not.toBeDisabled(); + }); + + it('shows proctored options but disables timed options when enableTimedExams is false', () => { + renderComponent({ + enableProctoredExams: true, + enableTimedExams: false, + }); + + expect(screen.getByLabelText('None')).toBeDisabled(); + expect(screen.getByLabelText('Timed')).toBeDisabled(); + + const questionIcon = screen + .getByText('Set as a special exam') + .parentElement.querySelector('svg'); + expect(questionIcon).toBeInTheDocument(); + }); + }); + + describe('default props and prop types', () => { + it('uses default value for enableTimedExams when not provided', () => { + const propsWithoutTimedExams = { ...defaultProps }; + delete propsWithoutTimedExams.enableTimedExams; + + render( + + {}}> + + + , + ); + + expect(screen.getByLabelText('None')).not.toBeDisabled(); + expect(screen.getByLabelText('Timed')).not.toBeDisabled(); + }); + + it('handles enableTimedExams prop correctly', () => { + renderComponent({ enableTimedExams: false }); + + expect(screen.getByLabelText('None')).toBeDisabled(); + expect(screen.getByLabelText('Timed')).toBeDisabled(); + }); + }); + + describe('exam type selection behavior', () => { + const mockSetFieldValue = jest.fn(); + + beforeEach(() => { + mockSetFieldValue.mockClear(); + }); + + it('handles timed exam selection', async () => { + const user = userEvent.setup(); + renderComponent({ setFieldValue: mockSetFieldValue }); + + const timedRadio = screen.getByLabelText('Timed'); + await user.click(timedRadio); + + expect(mockSetFieldValue).toHaveBeenCalledWith('isTimeLimited', true); + expect(mockSetFieldValue).toHaveBeenCalledWith('isOnboardingExam', false); + expect(mockSetFieldValue).toHaveBeenCalledWith('isPracticeExam', false); + expect(mockSetFieldValue).toHaveBeenCalledWith('isProctoredExam', false); + }); + + it('handles none exam selection', async () => { + const user = userEvent.setup(); + renderComponent({ + setFieldValue: mockSetFieldValue, + values: { + ...defaultProps.values, + isTimeLimited: true, + }, + }); + + const noneRadio = screen.getByLabelText('None'); + await user.click(noneRadio); + + expect(mockSetFieldValue).toHaveBeenCalledWith('isTimeLimited', false); + expect(mockSetFieldValue).toHaveBeenCalledWith('isOnboardingExam', false); + expect(mockSetFieldValue).toHaveBeenCalledWith('isPracticeExam', false); + expect(mockSetFieldValue).toHaveBeenCalledWith('isProctoredExam', false); + }); + + it('handles proctored exam selection', async () => { + const user = userEvent.setup(); + renderComponent({ + setFieldValue: mockSetFieldValue, + enableProctoredExams: true, + }); + + const proctoredRadio = screen.queryByLabelText('Proctored'); + + if (proctoredRadio) { + await user.click(proctoredRadio); + + expect(mockSetFieldValue).toHaveBeenCalledWith('isProctoredExam', true); + expect(mockSetFieldValue).toHaveBeenCalledWith('isTimeLimited', true); + expect(mockSetFieldValue).toHaveBeenCalledWith( + 'isOnboardingExam', + false, + ); + expect(mockSetFieldValue).toHaveBeenCalledWith('isPracticeExam', false); + } + }); + + it('handles practice exam selection', async () => { + const user = userEvent.setup(); + renderComponent({ + setFieldValue: mockSetFieldValue, + enableProctoredExams: true, + supportsOnboarding: false, // Explicitly set to false to show practice exam + }); + + const practiceRadio = screen.getByLabelText('Practice proctored'); + expect(practiceRadio).toBeInTheDocument(); + + await user.click(practiceRadio); + + expect(mockSetFieldValue).toHaveBeenCalledWith('isPracticeExam', true); + expect(mockSetFieldValue).toHaveBeenCalledWith('isProctoredExam', true); + expect(mockSetFieldValue).toHaveBeenCalledWith('isTimeLimited', true); + expect(mockSetFieldValue).toHaveBeenCalledWith('isOnboardingExam', false); + }); + + it('handles onboarding exam selection', async () => { + const user = userEvent.setup(); + renderComponent({ + setFieldValue: mockSetFieldValue, + enableProctoredExams: true, + supportsOnboarding: true, + }); + + const onboardingRadio = screen.queryByLabelText('Onboarding'); + + if (onboardingRadio) { + await user.click(onboardingRadio); + + expect(mockSetFieldValue).toHaveBeenCalledWith( + 'isOnboardingExam', + true, + ); + expect(mockSetFieldValue).toHaveBeenCalledWith('isProctoredExam', true); + expect(mockSetFieldValue).toHaveBeenCalledWith('isTimeLimited', true); + expect(mockSetFieldValue).toHaveBeenCalledWith('isPracticeExam', false); + } + }); + }); + + describe('exam type value calculation', () => { + it('shows onboarding exam when both isTimeLimited and isProctoredExam and isOnboardingExam are true', () => { + renderComponent({ + enableProctoredExams: true, + supportsOnboarding: true, + values: { + ...defaultProps.values, + isTimeLimited: true, + isProctoredExam: true, + isOnboardingExam: true, + }, + }); + + const onboardingRadio = screen.queryByLabelText('Onboarding'); + if (onboardingRadio) { + expect(onboardingRadio).toBeChecked(); + } + }); + + it('shows practice exam when both isTimeLimited and isProctoredExam and isPracticeExam are true', () => { + renderComponent({ + enableProctoredExams: true, + values: { + ...defaultProps.values, + isTimeLimited: true, + isProctoredExam: true, + isPracticeExam: true, + }, + }); + + const practiceRadio = screen.queryByLabelText('Practice proctored'); + if (practiceRadio) { + expect(practiceRadio).toBeChecked(); + } + }); + + it('shows proctored exam when both isTimeLimited and isProctoredExam are true', () => { + renderComponent({ + enableProctoredExams: true, + values: { + ...defaultProps.values, + isTimeLimited: true, + isProctoredExam: true, + }, + }); + + const proctoredRadio = screen.queryByLabelText('Proctored'); + if (proctoredRadio) { + expect(proctoredRadio).toBeChecked(); + } + }); + + it('shows timed exam when only isTimeLimited is true', () => { + renderComponent({ + values: { + ...defaultProps.values, + isTimeLimited: true, + }, + }); + + const timedRadio = screen.getByLabelText('Timed'); + expect(timedRadio).toBeChecked(); + }); + + it('shows none when no exam flags are set', () => { + renderComponent(); + + const noneRadio = screen.getByLabelText('None'); + expect(noneRadio).toBeChecked(); + }); + }); + + describe('time limit functionality', () => { + const mockSetFieldValue = jest.fn(); + + beforeEach(() => { + mockSetFieldValue.mockClear(); + }); + + it('displays time limit input when timed exam is selected', () => { + renderComponent({ + values: { + ...defaultProps.values, + isTimeLimited: true, + }, + }); + + const timeInput = screen.queryByDisplayValue('00:30'); + expect(timeInput).toBeInTheDocument(); + }); + + it('handles time limit changes', async () => { + const user = userEvent.setup(); + renderComponent({ + setFieldValue: mockSetFieldValue, + values: { + ...defaultProps.values, + isTimeLimited: true, + }, + }); + + const timeInput = screen.queryByDisplayValue('00:30'); + if (timeInput) { + await user.clear(timeInput); + await user.type(timeInput, '01:30'); + + expect(mockSetFieldValue).toHaveBeenCalledWith( + 'defaultTimeLimitMinutes', + 90, + ); + } + }); + + it('formats time correctly for different minute values', () => { + renderComponent({ + values: { + ...defaultProps.values, + isTimeLimited: true, + defaultTimeLimitMinutes: 0, + }, + }); + expect(screen.queryByDisplayValue('00:00')).toBeInTheDocument(); + + renderComponent({ + values: { + ...defaultProps.values, + isTimeLimited: true, + defaultTimeLimitMinutes: 90, + }, + }); + expect(screen.queryByDisplayValue('01:30')).toBeInTheDocument(); + + renderComponent({ + values: { + ...defaultProps.values, + isTimeLimited: true, + defaultTimeLimitMinutes: 605, + }, + }); + expect(screen.queryByDisplayValue('10:05')).toBeInTheDocument(); + }); + + it('handles NaN values in time formatting', () => { + renderComponent({ + values: { + ...defaultProps.values, + isTimeLimited: true, + defaultTimeLimitMinutes: NaN, + }, + }); + expect(screen.queryByDisplayValue('00:00')).toBeInTheDocument(); + }); + + it('handles undefined values in time formatting', () => { + renderComponent({ + values: { + ...defaultProps.values, + isTimeLimited: true, + defaultTimeLimitMinutes: undefined, + }, + }); + expect(screen.queryByDisplayValue('00:00')).toBeInTheDocument(); + }); + }); + + describe('alerts and warnings', () => { + it('shows warning when exam is locked and was not proctored', () => { + renderComponent({ + releasedToStudents: true, + wasExamEverLinkedWithExternal: true, + wasProctoredExam: false, + }); + + expect( + screen.getByText( + messages.proctoredExamLockedAndisNotProctoredExamAlert.defaultMessage, + ), + ).toBeInTheDocument(); + }); + + it('shows warning when exam is locked and was proctored', () => { + renderComponent({ + releasedToStudents: true, + wasExamEverLinkedWithExternal: true, + wasProctoredExam: true, + }); + + expect( + screen.getByText( + messages.proctoredExamLockedAndisProctoredExamAlert.defaultMessage, + ), + ).toBeInTheDocument(); + }); + + it('does not show warnings when exam is not locked', () => { + renderComponent({ + releasedToStudents: false, + wasExamEverLinkedWithExternal: false, + }); + + expect( + screen.queryByText( + messages.proctoredExamLockedAndisNotProctoredExamAlert.defaultMessage, + ), + ).not.toBeInTheDocument(); + expect( + screen.queryByText( + messages.proctoredExamLockedAndisProctoredExamAlert.defaultMessage, + ), + ).not.toBeInTheDocument(); + }); + }); + + describe('review rules section', () => { + it('shows review rules when showReviewRules is true and conditions are met', () => { + renderComponent({ + showReviewRules: true, + values: { + ...defaultProps.values, + isProctoredExam: true, + isPracticeExam: false, + isOnboardingExam: false, + }, + }); + + expect( + screen.getByText(messages.reviewRulesLabel.defaultMessage), + ).toBeInTheDocument(); + }); + + it('does not show review rules when showReviewRules is false', () => { + renderComponent({ + showReviewRules: false, + values: { + ...defaultProps.values, + isProctoredExam: true, + }, + }); + + expect( + screen.queryByText(messages.reviewRulesLabel.defaultMessage), + ).not.toBeInTheDocument(); + }); + + it('shows review rules with online proctoring link when available', () => { + renderComponent({ + showReviewRules: true, + onlineProctoringRules: 'https://example.com/rules', + values: { + ...defaultProps.values, + isProctoredExam: true, + isPracticeExam: false, + isOnboardingExam: false, + }, + }); + + expect( + screen.getByText(messages.reviewRulesLabel.defaultMessage), + ).toBeInTheDocument(); + const link = screen.queryByText('example.com/rules'); + if (link) { + expect(link).toBeInTheDocument(); + } + }); + + it('handles review rules text area changes', async () => { + const user = userEvent.setup(); + const mockSetFieldValue = jest.fn(); + + renderComponent({ + setFieldValue: mockSetFieldValue, + showReviewRules: true, + values: { + ...defaultProps.values, + isProctoredExam: true, + isPracticeExam: false, + isOnboardingExam: false, + }, + }); + + const textArea = screen.getByRole('textbox'); + await user.type(textArea, 'New review rules'); + + expect(mockSetFieldValue).toHaveBeenCalledWith( + 'examReviewRules', + expect.any(String), + ); + const examRulesCalls = mockSetFieldValue.mock.calls.filter( + (call) => call[0] === 'examReviewRules', + ); + expect(examRulesCalls.length).toBeGreaterThan(0); + const lastExamRulesCall = examRulesCalls[examRulesCalls.length - 1]; + expect(lastExamRulesCall[1]).toContain('s'); + }); + }); + + describe('prerequisites section', () => { + it('renders prerequisite settings', () => { + renderComponent({ + prereqs: [{ id: 'prereq1', name: 'Prerequisite 1' }], + }); + + expect(screen.getByText('Set as a special exam')).toBeInTheDocument(); + }); + }); +}); diff --git a/src/generic/configure-modal/AdvancedTab.jsx b/src/generic/configure-modal/AdvancedTab.tsx similarity index 65% rename from src/generic/configure-modal/AdvancedTab.jsx rename to src/generic/configure-modal/AdvancedTab.tsx index 4e6636206..884ecd933 100644 --- a/src/generic/configure-modal/AdvancedTab.jsx +++ b/src/generic/configure-modal/AdvancedTab.tsx @@ -1,26 +1,62 @@ import React, { useState } from 'react'; -import PropTypes from 'prop-types'; import moment from 'moment'; -import { Alert, Form, Hyperlink } from '@openedx/paragon'; import { - Warning as WarningIcon, -} from '@openedx/paragon/icons'; + Alert, + Form, + Hyperlink, + OverlayTrigger, + Tooltip, +} from '@openedx/paragon'; +import { Warning as WarningIcon, Question } from '@openedx/paragon/icons'; import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n'; import messages from './messages'; import PrereqSettings from './PrereqSettings'; -const AdvancedTab = ({ +interface ValuesProps { + isTimeLimited: boolean; + defaultTimeLimitMinutes?: number; + isPrereq?: boolean; + prereqUsageKey?: string; + prereqMinScore?: number; + prereqMinCompletion?: number; + isProctoredExam?: boolean; + isPracticeExam?: boolean; + isOnboardingExam?: boolean; + examReviewRules?: string; +} + +interface PrereqItem { + blockUsageKey: string; + blockDisplayName: string; +} + +interface AdvancedTabProps { + values: ValuesProps; + setFieldValue: (field: string, value: any) => void; + releasedToStudents: boolean; + prereqs?: PrereqItem[]; + wasExamEverLinkedWithExternal?: boolean; + enableProctoredExams?: boolean; + enableTimedExams?: boolean; + supportsOnboarding?: boolean; + wasProctoredExam?: boolean; + showReviewRules?: boolean; + onlineProctoringRules?: string; +} + +const AdvancedTab: React.FC = ({ values, setFieldValue, - prereqs, releasedToStudents, - wasExamEverLinkedWithExternal, - enableProctoredExams, - supportsOnboarding, - wasProctoredExam, - showReviewRules, - onlineProctoringRules, + prereqs = [], + wasExamEverLinkedWithExternal = false, + enableProctoredExams = false, + enableTimedExams = true, + supportsOnboarding = false, + wasProctoredExam = false, + showReviewRules = false, + onlineProctoringRules = '', }) => { const { isTimeLimited, @@ -46,7 +82,11 @@ const AdvancedTab = ({ examTypeValue = 'timed'; } - const formatHour = (hour) => { + const formatHour = (hour: number | undefined): string => { + if (hour === undefined) { + return '00:00'; + } + const hh = Math.floor(hour / 60); const mm = hour % 60; let hhs = `${hh}`; @@ -66,10 +106,12 @@ const AdvancedTab = ({ return `${hhs}:${mms}`; }; - const [timeLimit, setTimeLimit] = useState(formatHour(defaultTimeLimitMinutes)); + const [timeLimit, setTimeLimit] = useState( + formatHour(defaultTimeLimitMinutes), + ); const showReviewRulesDiv = showReviewRules && isProctoredExam && !isPracticeExam && !isOnboardingExam; - const handleChange = (e) => { + const handleChange = (e: React.ChangeEvent) => { if (e.target.value === 'timed') { setFieldValue('isTimeLimited', true); setFieldValue('isOnboardingExam', false); @@ -98,8 +140,10 @@ const AdvancedTab = ({ } }; - const setCurrentTimeLimit = (event) => { - const { validity: { valid } } = event.target; + const setCurrentTimeLimit = (event: React.ChangeEvent) => { + const { + validity: { valid }, + } = event.target; let { value } = event.target; value = value.trim(); if (value && valid) { @@ -115,12 +159,16 @@ const AdvancedTab = ({ <> {proctoredExamLockedIn && !wasProctoredExam && ( - + )} {proctoredExamLockedIn && wasProctoredExam && ( - + )} @@ -129,7 +177,26 @@ const AdvancedTab = ({ return ( <> -
+
+
+ +
+ {!enableTimedExams && ( + + + + )} + > + + + )} +

{renderAlerts()} - + } controlClassName="mw-1-25rem" > @@ -151,14 +219,18 @@ const AdvancedTab = ({ <> } + description={ + + } controlClassName="mw-1-25rem" > {supportsOnboarding ? ( } + description={ + + } value="onboardingExam" controlClassName="mw-1-25rem" > @@ -168,7 +240,9 @@ const AdvancedTab = ({ } + description={ + + } > @@ -176,7 +250,7 @@ const AdvancedTab = ({ )} - { isTimeLimited && ( + {isTimeLimited && (
@@ -189,10 +263,12 @@ const AdvancedTab = ({ pattern="^[0-9][0-9]:[0-5][0-9]$" /> - + + +
)} - { showReviewRulesDiv && ( + {showReviewRulesDiv && (
@@ -206,7 +282,7 @@ const AdvancedTab = ({ /> - { onlineProctoringRules ? ( + {onlineProctoringRules ? ( { @@ -172,7 +173,7 @@ const ConfigureModal = ({ case COURSE_BLOCK_NAMES.libraryContent.id: case COURSE_BLOCK_NAMES.splitTest.id: case COURSE_BLOCK_NAMES.component.id: - // groupAccess should be {partitionId: [group1, group2]} or {} if selectedPartitionIndex === -1 + // groupAccess should be {partitionId: [group1, group2]} or {} if selectedPartitionIndex === -1 if (data.selectedPartitionIndex >= 0) { const partitionId = userPartitionInfo.selectablePartitions[data.selectedPartitionIndex].id; groupAccess[partitionId] = data.selectedGroups.map(g => parseInt(g, 10)); @@ -239,6 +240,7 @@ const ConfigureModal = ({ releasedToStudents={releasedToStudents} wasExamEverLinkedWithExternal={wasExamEverLinkedWithExternal} enableProctoredExams={enableProctoredExams} + enableTimedExams={enableTimedExams} supportsOnboarding={supportsOnboarding} showReviewRules={showReviewRules} wasProctoredExam={isProctoredExam} @@ -325,6 +327,7 @@ ConfigureModal.propTypes = { onClose: PropTypes.func.isRequired, onConfigureSubmit: PropTypes.func.isRequired, enableProctoredExams: PropTypes.bool, + enableTimedExams: PropTypes.bool, currentItemData: PropTypes.shape({ displayName: PropTypes.string, start: PropTypes.string, diff --git a/src/generic/configure-modal/ConfigureModal.test.jsx b/src/generic/configure-modal/ConfigureModal.test.jsx index 40cea9c80..72753ff71 100644 --- a/src/generic/configure-modal/ConfigureModal.test.jsx +++ b/src/generic/configure-modal/ConfigureModal.test.jsx @@ -301,3 +301,131 @@ describe(' for XBlock', () => { expect(queryByText(messages.discussionEnabledDescription.defaultMessage)).not.toBeInTheDocument(); }); }); + +describe(' with enableTimedExams prop', () => { + beforeEach(() => { + initializeMockApp({ + authenticatedUser: { + userId: 3, + username: 'abc123', + administrator: true, + roles: [], + }, + }); + + store = initializeStore(); + }); + + const renderWithTimedExamsProps = (enableTimedExams = true) => render( + + + + + , + , + ); + + it('passes enableTimedExams=true to AdvancedTab', async () => { + const user = userEvent.setup(); + const { getByRole, getByText } = renderWithTimedExamsProps(true); + + const advancedTab = getByRole('tab', { + name: messages.advancedTabTitle.defaultMessage, + }); + await user.click(advancedTab); + + expect( + getByText(messages.setSpecialExam.defaultMessage), + ).toBeInTheDocument(); + + const noneRadio = getByRole('radio', { + name: messages.none.defaultMessage, + }); + const timedRadio = getByRole('radio', { + name: messages.timed.defaultMessage, + }); + + expect(noneRadio).not.toBeDisabled(); + expect(timedRadio).not.toBeDisabled(); + }); + + it('passes enableTimedExams=false to AdvancedTab and shows disabled state', async () => { + const user = userEvent.setup(); + const { getByRole, getByText } = renderWithTimedExamsProps(false); + + const advancedTab = getByRole('tab', { + name: messages.advancedTabTitle.defaultMessage, + }); + await user.click(advancedTab); + + expect( + getByText(messages.setSpecialExam.defaultMessage), + ).toBeInTheDocument(); + + const noneRadio = getByRole('radio', { + name: messages.none.defaultMessage, + }); + const timedRadio = getByRole('radio', { + name: messages.timed.defaultMessage, + }); + + expect(noneRadio).toBeDisabled(); + expect(timedRadio).toBeDisabled(); + }); + + it('shows tooltip when enableTimedExams is false', async () => { + const user = userEvent.setup(); + const { getByRole } = renderWithTimedExamsProps(false); + + const advancedTab = getByRole('tab', { + name: messages.advancedTabTitle.defaultMessage, + }); + await user.click(advancedTab); + + const buttons = getByRole('tab', { + name: messages.advancedTabTitle.defaultMessage, + }).parentElement.querySelectorAll('button'); + expect(buttons.length).toBeGreaterThan(0); + }); + + it('defaults enableTimedExams to false when not provided', async () => { + const user = userEvent.setup(); + + const { getByRole } = render( + + + + + , + , + ); + + const advancedTab = getByRole('tab', { + name: messages.advancedTabTitle.defaultMessage, + }); + await user.click(advancedTab); + + const noneRadio = getByRole('radio', { + name: messages.none.defaultMessage, + }); + const timedRadio = getByRole('radio', { + name: messages.timed.defaultMessage, + }); + + expect(noneRadio).toBeDisabled(); + expect(timedRadio).toBeDisabled(); + }); +}); diff --git a/src/generic/configure-modal/messages.js b/src/generic/configure-modal/messages.js index 7a141ad96..c6707ffd7 100644 --- a/src/generic/configure-modal/messages.js +++ b/src/generic/configure-modal/messages.js @@ -231,6 +231,10 @@ const messages = defineMessages({ id: 'course-authoring.course-outline.configure-modal.advanced-tab.practice-exam-description', defaultMessage: 'Use a practice proctored exam to introduce learners to the proctoring tools and processes. Results of a practice exam do not affect a learner\'s grade.', }, + timedExamsDisabledTooltip: { + id: 'course-authoring.course-outline.configure-modal.advanced-tab.timed-exams-disabled-tooltip', + defaultMessage: 'Timed exams are not enabled for this Open edX instance', + }, advancedTabTitle: { id: 'course-authoring.course-outline.configure-modal.advanced-tab.title', defaultMessage: 'Advanced',