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
This commit is contained in:
Victor Navarro
2025-08-11 14:24:38 -06:00
committed by GitHub
parent f0c5a513de
commit 5bdef7cffa
11 changed files with 986 additions and 67 deletions

View File

@@ -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}
/>
<DeleteModal

View File

@@ -0,0 +1,41 @@
import { getTimedExamsFlag, getProctoredExamsFlag } from './selectors';
const mockState = {
courseOutline: {
enableTimedExams: true,
enableProctoredExams: false,
},
};
describe('course-outline selectors', () => {
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);
});
});
});

View File

@@ -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;

View File

@@ -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);
});
});
});

View File

@@ -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 }) => {

View File

@@ -59,6 +59,7 @@ export interface CourseOutlineState {
currentItem: XBlock | {};
actions: XBlockActions;
enableProctoredExams: boolean;
enableTimedExams: boolean;
pasteFileNotices: object;
createdOn: null | Date;
}

View File

@@ -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(
<IntlProvider locale="en">
<Formik initialValues={defaultProps.values} onSubmit={() => {}}>
<AdvancedTab {...defaultProps} {...props} />
</Formik>
</IntlProvider>,
);
describe('<AdvancedTab /> 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(
<IntlProvider locale="en">
<Formik initialValues={defaultProps.values} onSubmit={() => {}}>
<AdvancedTab {...propsWithoutTimedExams} />
</Formik>
</IntlProvider>,
);
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();
});
});
});

View File

@@ -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<AdvancedTabProps> = ({
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<HTMLInputElement>) => {
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<HTMLInputElement>) => {
const {
validity: { valid },
} = event.target;
let { value } = event.target;
value = value.trim();
if (value && valid) {
@@ -115,12 +159,16 @@ const AdvancedTab = ({
<>
{proctoredExamLockedIn && !wasProctoredExam && (
<Alert variant="warning" icon={WarningIcon}>
<FormattedMessage {...messages.proctoredExamLockedAndisNotProctoredExamAlert} />
<FormattedMessage
{...messages.proctoredExamLockedAndisNotProctoredExamAlert}
/>
</Alert>
)}
{proctoredExamLockedIn && wasProctoredExam && (
<Alert variant="warning" icon={WarningIcon}>
<FormattedMessage {...messages.proctoredExamLockedAndisProctoredExamAlert} />
<FormattedMessage
{...messages.proctoredExamLockedAndisProctoredExamAlert}
/>
</Alert>
)}
</>
@@ -129,7 +177,26 @@ const AdvancedTab = ({
return (
<>
<h5 className="mt-4 text-gray-700"><FormattedMessage {...messages.setSpecialExam} /></h5>
<div className="d-flex align-items-center mt-4">
<h5 className="text-gray-700 mb-0">
<FormattedMessage {...messages.setSpecialExam} />
</h5>
{!enableTimedExams && (
<OverlayTrigger
placement="top"
overlay={(
<Tooltip id={messages.timedExamsDisabledTooltip.id}>
<FormattedMessage {...messages.timedExamsDisabledTooltip} />
</Tooltip>
)}
>
<Question
className="ml-2 text-gray-500"
style={{ cursor: 'help' }}
/>
</OverlayTrigger>
)}
</div>
<hr />
<Form.RadioSet
name="specialExam"
@@ -137,11 +204,12 @@ const AdvancedTab = ({
value={examTypeValue}
>
{renderAlerts()}
<Form.Radio value="none">
<Form.Radio value="none" disabled={!enableTimedExams}>
<FormattedMessage {...messages.none} />
</Form.Radio>
<Form.Radio
value="timed"
disabled={!enableTimedExams}
description={<FormattedMessage {...messages.timedDescription} />}
controlClassName="mw-1-25rem"
>
@@ -151,14 +219,18 @@ const AdvancedTab = ({
<>
<Form.Radio
value="proctoredExam"
description={<FormattedMessage {...messages.proctoredExamDescription} />}
description={
<FormattedMessage {...messages.proctoredExamDescription} />
}
controlClassName="mw-1-25rem"
>
<FormattedMessage {...messages.proctoredExam} />
</Form.Radio>
{supportsOnboarding ? (
<Form.Radio
description={<FormattedMessage {...messages.onboardingExamDescription} />}
description={
<FormattedMessage {...messages.onboardingExamDescription} />
}
value="onboardingExam"
controlClassName="mw-1-25rem"
>
@@ -168,7 +240,9 @@ const AdvancedTab = ({
<Form.Radio
value="practiceExam"
controlClassName="mw-1-25rem"
description={<FormattedMessage {...messages.practiceExamDescription} />}
description={
<FormattedMessage {...messages.practiceExamDescription} />
}
>
<FormattedMessage {...messages.practiceExam} />
</Form.Radio>
@@ -176,7 +250,7 @@ const AdvancedTab = ({
</>
)}
</Form.RadioSet>
{ isTimeLimited && (
{isTimeLimited && (
<div className="mt-3" data-testid="advanced-tab-hours-picker-wrapper">
<Form.Group>
<Form.Label>
@@ -189,10 +263,12 @@ const AdvancedTab = ({
pattern="^[0-9][0-9]:[0-5][0-9]$"
/>
</Form.Group>
<Form.Text><FormattedMessage {...messages.timeLimitDescription} /></Form.Text>
<Form.Text>
<FormattedMessage {...messages.timeLimitDescription} />
</Form.Text>
</div>
)}
{ showReviewRulesDiv && (
{showReviewRulesDiv && (
<div className="mt-3">
<Form.Group>
<Form.Label>
@@ -206,7 +282,7 @@ const AdvancedTab = ({
/>
</Form.Group>
<Form.Text>
{ onlineProctoringRules ? (
{onlineProctoringRules ? (
<FormattedMessage
{...messages.reviewRulesDescriptionWithLink}
values={{
@@ -238,41 +314,4 @@ const AdvancedTab = ({
);
};
AdvancedTab.defaultProps = {
prereqs: [],
wasExamEverLinkedWithExternal: false,
enableProctoredExams: false,
supportsOnboarding: false,
wasProctoredExam: false,
showReviewRules: false,
onlineProctoringRules: '',
};
AdvancedTab.propTypes = {
values: PropTypes.shape({
isTimeLimited: PropTypes.bool.isRequired,
defaultTimeLimitMinutes: PropTypes.number,
isPrereq: PropTypes.bool,
prereqUsageKey: PropTypes.string,
prereqMinScore: PropTypes.number,
prereqMinCompletion: PropTypes.number,
isProctoredExam: PropTypes.bool,
isPracticeExam: PropTypes.bool,
isOnboardingExam: PropTypes.bool,
examReviewRules: PropTypes.string,
}).isRequired,
setFieldValue: PropTypes.func.isRequired,
prereqs: PropTypes.arrayOf(PropTypes.shape({
blockUsageKey: PropTypes.string.isRequired,
blockDisplayName: PropTypes.string.isRequired,
})),
releasedToStudents: PropTypes.bool.isRequired,
wasExamEverLinkedWithExternal: PropTypes.bool,
enableProctoredExams: PropTypes.bool,
supportsOnboarding: PropTypes.bool,
wasProctoredExam: PropTypes.bool,
showReviewRules: PropTypes.bool,
onlineProctoringRules: PropTypes.string,
};
export default AdvancedTab;

View File

@@ -27,6 +27,7 @@ const ConfigureModal = ({
onConfigureSubmit,
currentItemData,
enableProctoredExams = false,
enableTimedExams = false,
isXBlockComponent = false,
isSelfPaced,
}) => {
@@ -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,

View File

@@ -301,3 +301,131 @@ describe('<ConfigureModal /> for XBlock', () => {
expect(queryByText(messages.discussionEnabledDescription.defaultMessage)).not.toBeInTheDocument();
});
});
describe('<ConfigureModal /> with enableTimedExams prop', () => {
beforeEach(() => {
initializeMockApp({
authenticatedUser: {
userId: 3,
username: 'abc123',
administrator: true,
roles: [],
},
});
store = initializeStore();
});
const renderWithTimedExamsProps = (enableTimedExams = true) => render(
<AppProvider store={store}>
<IntlProvider locale="en">
<ConfigureModal
isOpen
onClose={onCloseMock}
onConfigureSubmit={onConfigureSubmitMock}
currentItemData={currentSubsectionMock}
enableTimedExams={enableTimedExams}
isSelfPaced={false}
/>
</IntlProvider>
,
</AppProvider>,
);
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(
<AppProvider store={store}>
<IntlProvider locale="en">
<ConfigureModal
isOpen
onClose={onCloseMock}
onConfigureSubmit={onConfigureSubmitMock}
currentItemData={currentSubsectionMock}
isSelfPaced={false}
/>
</IntlProvider>
,
</AppProvider>,
);
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();
});
});

View File

@@ -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',