diff --git a/.gitignore b/.gitignore index 7ba8a0e7f..9770f7309 100755 --- a/.gitignore +++ b/.gitignore @@ -20,3 +20,6 @@ temp/babel-plugin-react-intl /temp /.vscode /module.config.js + +# Local environment overrides +.env.private diff --git a/src/course-outline/CourseOutline.jsx b/src/course-outline/CourseOutline.jsx index 020cc0a42..429d0e410 100644 --- a/src/course-outline/CourseOutline.jsx +++ b/src/course-outline/CourseOutline.jsx @@ -15,10 +15,7 @@ import { Warning as WarningIcon, } from '@edx/paragon/icons'; import { useSelector } from 'react-redux'; -import { - DraggableList, - ErrorAlert, -} from '@edx/frontend-lib-content-components'; +import { DraggableList } from '@edx/frontend-lib-content-components'; import { arrayMove } from '@dnd-kit/sortable'; import { LoadingSpinner } from '../generic/Loading'; @@ -41,6 +38,7 @@ import EmptyPlaceholder from './empty-placeholder/EmptyPlaceholder'; import PublishModal from './publish-modal/PublishModal'; import ConfigureModal from './configure-modal/ConfigureModal'; import DeleteModal from './delete-modal/DeleteModal'; +import PageAlerts from './page-alerts/PageAlerts'; import { useCourseOutline } from './hooks'; import messages from './messages'; @@ -97,6 +95,15 @@ const CourseOutline = ({ courseId }) => { handleUnitDragAndDrop, handleCopyToClipboardClick, handlePasteClipboardClick, + notificationDismissUrl, + discussionsSettings, + discussionsIncontextFeedbackUrl, + discussionsIncontextLearnmoreUrl, + deprecatedBlocksInfo, + proctoringErrors, + mfeProctoredExamSettingsUrl, + handleDismissNotification, + advanceSettingsUrl, } = useCourseOutline({ courseId }); const [sections, setSections] = useState(sectionsList); @@ -250,9 +257,18 @@ const CourseOutline = ({ courseId }) => {
- - {intl.formatMessage(messages.alertFailedGeneric, { actionName: 'save', type: 'changes' })} - + {showSuccessAlert ? ( { subsection.childInfo.children, )} onCopyToClipboardClick={handleCopyToClipboardClick} + discussionsSettings={discussionsSettings} /> ))} diff --git a/src/course-outline/CourseOutline.test.jsx b/src/course-outline/CourseOutline.test.jsx index 6688b29e9..3c2dc7649 100644 --- a/src/course-outline/CourseOutline.test.jsx +++ b/src/course-outline/CourseOutline.test.jsx @@ -1,11 +1,12 @@ import { - act, render, waitFor, cleanup, fireEvent, within, + act, render, waitFor, fireEvent, within, } from '@testing-library/react'; import { IntlProvider } from '@edx/frontend-platform/i18n'; import { AppProvider } from '@edx/frontend-platform/react'; import { initializeMockApp } from '@edx/frontend-platform'; import MockAdapter from 'axios-mock-adapter'; import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; +import { cloneDeep } from 'lodash'; import { getCourseBestPracticesApiUrl, @@ -45,6 +46,7 @@ import statusBarMessages from './status-bar/messages'; import configureModalMessages from './configure-modal/messages'; import pasteButtonMessages from './paste-button/messages'; import subsectionMessages from './subsection-card/messages'; +import pageAlertMessages from './page-alerts/messages'; let axiosMock; let store; @@ -69,6 +71,13 @@ jest.mock('../help-urls/hooks', () => ({ }), })); +jest.mock('@edx/frontend-platform/i18n', () => ({ + ...jest.requireActual('@edx/frontend-platform/i18n'), + useIntl: () => ({ + formatMessage: (message) => message.defaultMessage, + }), +})); + const RootWrapper = () => ( @@ -118,7 +127,7 @@ describe('', () => { }); it('check video sharing option udpates correctly', async () => { - const { findByTestId } = render(); + const { findByLabelText } = render(); axiosMock .onPost(getCourseBlockApiUrl(courseId), { @@ -127,13 +136,10 @@ describe('', () => { }, }) .reply(200); - const optionDropdownWrapper = await findByTestId('video-sharing-wrapper'); - const optionDropdown = await within(optionDropdownWrapper).findByRole('button'); - await act(async () => fireEvent.click(optionDropdown)); - const allOffOption = await within(optionDropdownWrapper).findByText( - statusBarMessages.videoSharingAllOffText.defaultMessage, + const optionDropdown = await findByLabelText(statusBarMessages.videoSharingTitle.defaultMessage); + await act( + async () => fireEvent.change(optionDropdown, { target: { value: VIDEO_SHARING_OPTIONS.allOff } }), ); - await act(async () => fireEvent.click(allOffOption)); expect(axiosMock.history.post.length).toBe(1); expect(axiosMock.history.post[0].data).toBe(JSON.stringify({ @@ -144,7 +150,7 @@ describe('', () => { }); it('check video sharing option shows error on failure', async () => { - const { findByTestId, queryByRole } = render(); + const { findByLabelText, queryByRole } = render(); axiosMock .onPost(getCourseBlockApiUrl(courseId), { @@ -153,13 +159,10 @@ describe('', () => { }, }) .reply(500); - const optionDropdownWrapper = await findByTestId('video-sharing-wrapper'); - const optionDropdown = await within(optionDropdownWrapper).findByRole('button'); - await act(async () => fireEvent.click(optionDropdown)); - const allOffOption = await within(optionDropdownWrapper).findByText( - statusBarMessages.videoSharingAllOffText.defaultMessage, + const optionDropdown = await findByLabelText(statusBarMessages.videoSharingTitle.defaultMessage); + await act( + async () => fireEvent.change(optionDropdown, { target: { value: VIDEO_SHARING_OPTIONS.allOff } }), ); - await act(async () => fireEvent.click(allOffOption)); expect(axiosMock.history.post.length).toBe(1); expect(axiosMock.history.post[0].data).toBe(JSON.stringify({ @@ -168,7 +171,10 @@ describe('', () => { }, })); - expect(queryByRole('alert')).toBeInTheDocument(); + const alertElement = queryByRole('alert'); + expect(alertElement).toHaveTextContent( + pageAlertMessages.alertFailedGeneric.defaultMessage, + ); }); it('render error alert after failed reindex correctly', async () => { @@ -337,7 +343,6 @@ describe('', () => { }); it('render CourseOutline component without sections correctly', async () => { - cleanup(); axiosMock .onGet(getCourseOutlineIndexApiUrl(courseId)) .reply(200, courseOutlineIndexWithoutSections); @@ -349,6 +354,25 @@ describe('', () => { }); }); + it('render configuration alerts and check dismiss query', async () => { + axiosMock + .onGet(getCourseOutlineIndexApiUrl(courseId)) + .reply(200, { + ...courseOutlineIndexMock, + notificationDismissUrl: '/some/url', + }); + + const { findByRole } = render(); + expect(await findByRole('alert')).toBeInTheDocument(); + const dismissBtn = await findByRole('button', { name: 'Dismiss' }); + axiosMock + .onDelete('/some/url') + .reply(204); + fireEvent.click(dismissBtn); + + expect(axiosMock.history.delete.length).toBe(1); + }); + it('check edit title works for section, subsection and unit', async () => { const { findAllByTestId } = render(); const checkEditTitle = async (section, element, item, newName, elementName) => { @@ -545,7 +569,7 @@ describe('', () => { const checkPublishBtn = async (item, element, elementName) => { expect( - await within(element).findByTestId(`${elementName}-card-header__badge-status`), + (await within(element).getAllByRole('status'))[0], `Failed for ${elementName}!`, ).toHaveTextContent(cardHeaderMessages.statusBadgeDraft.defaultMessage); @@ -601,7 +625,7 @@ describe('', () => { await act(async () => fireEvent.click(confirmButton)); expect( - await within(element).findByTestId(`${elementName}-card-header__badge-status`), + (await within(element).getAllByRole('status'))[0], `Failed for ${elementName}!`, ).toHaveTextContent(cardHeaderMessages.statusBadgePublishedNotLive.defaultMessage); }; @@ -672,43 +696,43 @@ describe('', () => { findAllByTestId, findByTestId, } = render(); - const section = courseOutlineIndexMock.courseStructure.childInfo.children[0]; - const subsection = section.childInfo.children[0]; - const newReleaseDate = '2025-08-10T05:00:00Z'; - const newGraderType = 'Homework'; - const newDue = '2025-09-10T00:00:00Z'; - const isTimeLimited = true; - const defaultTimeLimitMinutes = 3270; + const section = cloneDeep(courseOutlineIndexMock.courseStructure.childInfo.children[0]); + const [subsection] = section.childInfo.children; + const expectedRequestData = { + publish: 'republish', + graderType: 'Homework', + isPrereq: false, + prereqMinScore: 100, + prereqMinCompletion: 100, + metadata: { + visible_to_staff_only: null, + due: '2025-09-10T05:00:00Z', + hide_after_due: true, + show_correctness: 'always', + is_practice_exam: false, + is_time_limited: true, + is_proctored_enabled: false, + exam_review_rules: '', + default_time_limit_minutes: 3270, + is_onboarding_exam: false, + start: '2025-08-10T00:00:00Z', + }, + }; axiosMock - .onPost(getCourseItemApiUrl(subsection.id), { - publish: 'republish', - graderType: newGraderType, - metadata: { - visible_to_staff_only: null, - due: newDue, - hide_after_due: false, - show_correctness: 'always', - is_practice_exam: false, - is_time_limited: isTimeLimited, - exam_review_rules: '', - is_proctored_enabled: false, - default_time_limit_minutes: defaultTimeLimitMinutes, - is_onboarding_exam: false, - start: newReleaseDate, - }, - }) + .onPost(getCourseItemApiUrl(subsection.id), expectedRequestData) .reply(200, { dummy: 'value' }); const [currentSection] = await findAllByTestId('section-card'); const [firstSubsection] = await within(currentSection).findAllByTestId('subsection-card'); const subsectionDropdownButton = await within(firstSubsection).findByTestId('subsection-card-header__menu-button'); - subsection.start = newReleaseDate; - subsection.due = newDue; - subsection.format = newGraderType; - subsection.isTimeLimited = isTimeLimited; - subsection.defaultTimeLimitMinutes = defaultTimeLimitMinutes; + subsection.start = expectedRequestData.metadata.start; + subsection.due = expectedRequestData.metadata.due; + subsection.format = expectedRequestData.graderType; + subsection.isTimeLimited = expectedRequestData.metadata.is_time_limited; + subsection.defaultTimeLimitMinutes = expectedRequestData.metadata.default_time_limit_minutes; + subsection.hideAfterDue = expectedRequestData.metadata.hideAfterDue; section.childInfo.children[0] = subsection; axiosMock .onGet(getXBlockApiUrl(section.id)) @@ -720,15 +744,25 @@ describe('', () => { // update fields let configureModal = await findByTestId('configure-modal'); - expect(await within(configureModal).findByText(newGraderType)).toBeInTheDocument(); + expect(await within(configureModal).findByText(expectedRequestData.graderType)).toBeInTheDocument(); let releaseDateStack = await within(configureModal).findByTestId('release-date-stack'); let releaseDatePicker = await within(releaseDateStack).findByPlaceholderText('MM/DD/YYYY'); fireEvent.change(releaseDatePicker, { target: { value: '08/10/2025' } }); + let releaseDateTimePicker = await within(releaseDateStack).findByPlaceholderText('HH:MM'); + fireEvent.change(releaseDateTimePicker, { target: { value: '00:00' } }); let dueDateStack = await within(configureModal).findByTestId('due-date-stack'); let dueDatePicker = await within(dueDateStack).findByPlaceholderText('MM/DD/YYYY'); fireEvent.change(dueDatePicker, { target: { value: '09/10/2025' } }); + let dueDateTimePicker = await within(dueDateStack).findByPlaceholderText('HH:MM'); + fireEvent.change(dueDateTimePicker, { target: { value: '05:00' } }); let graderTypeDropdown = await within(configureModal).findByTestId('grader-type-select'); - fireEvent.change(graderTypeDropdown, { target: { value: newGraderType } }); + fireEvent.change(graderTypeDropdown, { target: { value: expectedRequestData.graderType } }); + + // visibility tab + const visibilityTab = await within(configureModal).findByRole('tab', { name: configureModalMessages.visibilityTabTitle.defaultMessage }); + fireEvent.click(visibilityTab); + const visibilityRadioButtons = await within(configureModal).findAllByRole('radio'); + fireEvent.click(visibilityRadioButtons[1]); let advancedTab = await within(configureModal).findByRole('tab', { name: configureModalMessages.advancedTabTitle.defaultMessage }); fireEvent.click(advancedTab); @@ -742,23 +776,7 @@ describe('', () => { // verify request expect(axiosMock.history.post.length).toBe(1); - expect(axiosMock.history.post[0].data).toBe(JSON.stringify({ - publish: 'republish', - graderType: newGraderType, - metadata: { - visible_to_staff_only: null, - due: newDue, - hide_after_due: false, - show_correctness: 'always', - is_practice_exam: false, - is_time_limited: isTimeLimited, - exam_review_rules: '', - is_proctored_enabled: false, - default_time_limit_minutes: defaultTimeLimitMinutes, - is_onboarding_exam: false, - start: newReleaseDate, - }, - })); + expect(axiosMock.history.post[0].data).toBe(JSON.stringify(expectedRequestData)); // reopen modal and check values await act(async () => fireEvent.click(subsectionDropdownButton)); @@ -768,11 +786,15 @@ describe('', () => { releaseDateStack = await within(configureModal).findByTestId('release-date-stack'); releaseDatePicker = await within(releaseDateStack).findByPlaceholderText('MM/DD/YYYY'); expect(releaseDatePicker).toHaveValue('08/10/2025'); + releaseDateTimePicker = await within(releaseDateStack).findByPlaceholderText('HH:MM'); + expect(releaseDateTimePicker).toHaveValue('00:00'); dueDateStack = await await within(configureModal).findByTestId('due-date-stack'); dueDatePicker = await within(dueDateStack).findByPlaceholderText('MM/DD/YYYY'); expect(dueDatePicker).toHaveValue('09/10/2025'); + dueDateTimePicker = await within(dueDateStack).findByPlaceholderText('HH:MM'); + expect(dueDateTimePicker).toHaveValue('05:00'); graderTypeDropdown = await within(configureModal).findByTestId('grader-type-select'); - expect(graderTypeDropdown).toHaveValue(newGraderType); + expect(graderTypeDropdown).toHaveValue(expectedRequestData.graderType); advancedTab = await within(configureModal).findByRole('tab', { name: configureModalMessages.advancedTabTitle.defaultMessage }); fireEvent.click(advancedTab); @@ -784,6 +806,444 @@ describe('', () => { expect(hours).toHaveValue('54:30'); }); + it('check prereq and proctoring settings in configure modal for subsection', async () => { + const { + findAllByTestId, + findByTestId, + } = render(); + const section = cloneDeep(courseOutlineIndexMock.courseStructure.childInfo.children[0]); + const [subsection, secondSubsection] = section.childInfo.children; + const expectedRequestData = { + publish: 'republish', + graderType: 'notgraded', + isPrereq: true, + prereqUsageKey: secondSubsection.id, + prereqMinScore: 80, + prereqMinCompletion: 90, + metadata: { + visible_to_staff_only: true, + due: '', + hide_after_due: false, + show_correctness: 'always', + is_practice_exam: false, + is_time_limited: true, + is_proctored_enabled: true, + exam_review_rules: 'some rules for proctored exams', + default_time_limit_minutes: 30, + is_onboarding_exam: false, + start: '1970-01-01T05:00:00Z', + }, + }; + + axiosMock + .onPost(getCourseItemApiUrl(subsection.id), expectedRequestData) + .reply(200, { dummy: 'value' }); + + const [currentSection] = await findAllByTestId('section-card'); + const [firstSubsection] = await within(currentSection).findAllByTestId('subsection-card'); + const subsectionDropdownButton = await within(firstSubsection).findByTestId('subsection-card-header__menu-button'); + + subsection.isTimeLimited = expectedRequestData.metadata.is_time_limited; + subsection.defaultTimeLimitMinutes = expectedRequestData.metadata.default_time_limit_minutes; + subsection.isProctoredExam = expectedRequestData.metadata.is_proctored_enabled; + subsection.isPracticeExam = expectedRequestData.metadata.is_practice_exam; + subsection.isOnboardingExam = expectedRequestData.metadata.is_onboarding_exam; + subsection.examReviewRules = expectedRequestData.metadata.exam_review_rules; + subsection.isPrereq = expectedRequestData.isPrereq; + subsection.prereq = expectedRequestData.prereqUsageKey; + subsection.prereqMinScore = expectedRequestData.prereqMinScore; + subsection.prereqMinCompletion = expectedRequestData.prereqMinCompletion; + section.childInfo.children[0] = subsection; + axiosMock + .onGet(getXBlockApiUrl(section.id)) + .reply(200, section); + + fireEvent.click(subsectionDropdownButton); + const configureBtn = await within(firstSubsection).findByTestId('subsection-card-header__menu-configure-button'); + fireEvent.click(configureBtn); + + // update fields + let configureModal = await findByTestId('configure-modal'); + let advancedTab = await within(configureModal).findByRole( + 'tab', + { name: configureModalMessages.advancedTabTitle.defaultMessage }, + ); + + // visibility tab + const visibilityTab = await within(configureModal).findByRole('tab', { name: configureModalMessages.visibilityTabTitle.defaultMessage }); + fireEvent.click(visibilityTab); + const visibilityRadioButtons = await within(configureModal).findAllByRole('radio'); + fireEvent.click(visibilityRadioButtons[2]); + + fireEvent.click(advancedTab); + let radioButtons = await within(configureModal).findAllByRole('radio'); + fireEvent.click(radioButtons[2]); + let hoursWrapper = await within(configureModal).findByTestId('advanced-tab-hours-picker-wrapper'); + let hours = await within(hoursWrapper).findByPlaceholderText('HH:MM'); + fireEvent.change(hours, { target: { value: '00:30' } }); + // select a prerequisite + const prereqSelect = await within(configureModal).findByRole('combobox'); + fireEvent.change(prereqSelect, { target: { value: expectedRequestData.prereqUsageKey } }); + + // update minimum score and completion percentage + let prereqMinScoreInput = await within(configureModal).findByLabelText( + configureModalMessages.minScoreLabel.defaultMessage, + ); + fireEvent.change(prereqMinScoreInput, { target: { value: expectedRequestData.prereqMinScore } }); + let prereqMinCompletionInput = await within(configureModal).findByLabelText( + configureModalMessages.minCompletionLabel.defaultMessage, + ); + fireEvent.change(prereqMinCompletionInput, { target: { value: expectedRequestData.prereqMinCompletion } }); + + // enable this subsection to be used as prerequisite by other subsections + let prereqCheckbox = await within(configureModal).findByLabelText( + configureModalMessages.prereqCheckboxLabel.defaultMessage, + ); + fireEvent.click(prereqCheckbox); + + // fill some rules for proctored exams + let examsRulesInput = await within(configureModal).findByLabelText( + configureModalMessages.reviewRulesLabel.defaultMessage, + ); + fireEvent.change(examsRulesInput, { target: { value: expectedRequestData.metadata.exam_review_rules } }); + + const saveButton = await within(configureModal).findByTestId('configure-save-button'); + await act(async () => fireEvent.click(saveButton)); + + // verify request + expect(axiosMock.history.post.length).toBe(1); + expect(axiosMock.history.post[0].data).toBe(JSON.stringify(expectedRequestData)); + + // reopen modal and check values + await act(async () => fireEvent.click(subsectionDropdownButton)); + await act(async () => fireEvent.click(configureBtn)); + + configureModal = await findByTestId('configure-modal'); + advancedTab = await within(configureModal).findByRole('tab', { + name: configureModalMessages.advancedTabTitle.defaultMessage, + }); + fireEvent.click(advancedTab); + radioButtons = await within(configureModal).findAllByRole('radio'); + expect(radioButtons[0]).toHaveProperty('checked', false); + expect(radioButtons[1]).toHaveProperty('checked', false); + expect(radioButtons[2]).toHaveProperty('checked', true); + hoursWrapper = await within(configureModal).findByTestId('advanced-tab-hours-picker-wrapper'); + hours = await within(hoursWrapper).findByPlaceholderText('HH:MM'); + expect(hours).toHaveValue('00:30'); + prereqCheckbox = await within(configureModal).findByLabelText( + configureModalMessages.prereqCheckboxLabel.defaultMessage, + ); + expect(prereqCheckbox).toBeChecked(); + const prereqSelectOption = await within(configureModal).findByRole('option', { selected: true }); + expect(prereqSelectOption).toHaveAttribute('value', expectedRequestData.prereqUsageKey); + examsRulesInput = await within(configureModal).findByLabelText( + configureModalMessages.reviewRulesLabel.defaultMessage, + ); + expect(examsRulesInput).toHaveTextContent(expectedRequestData.metadata.exam_review_rules); + + prereqMinScoreInput = await within(configureModal).findByLabelText( + configureModalMessages.minScoreLabel.defaultMessage, + ); + expect(prereqMinScoreInput).toHaveAttribute('value', `${expectedRequestData.prereqMinScore}`); + prereqMinCompletionInput = await within(configureModal).findByLabelText( + configureModalMessages.minCompletionLabel.defaultMessage, + ); + expect(prereqMinCompletionInput).toHaveAttribute('value', `${expectedRequestData.prereqMinCompletion}`); + }); + + it('check practice proctoring settings in configure modal', async () => { + const { + findAllByTestId, + findByTestId, + } = render(); + const section = cloneDeep(courseOutlineIndexMock.courseStructure.childInfo.children[0]); + const [subsection] = section.childInfo.children; + const expectedRequestData = { + publish: 'republish', + graderType: 'notgraded', + isPrereq: false, + prereqMinScore: 100, + prereqMinCompletion: 100, + metadata: { + visible_to_staff_only: null, + due: '', + hide_after_due: false, + show_correctness: 'never', + is_practice_exam: true, + is_time_limited: true, + is_proctored_enabled: true, + exam_review_rules: '', + default_time_limit_minutes: 30, + is_onboarding_exam: false, + start: '1970-01-01T05:00:00Z', + }, + }; + + axiosMock + .onPost(getCourseItemApiUrl(subsection.id), expectedRequestData) + .reply(200, { dummy: 'value' }); + + const [currentSection] = await findAllByTestId('section-card'); + const [firstSubsection] = await within(currentSection).findAllByTestId('subsection-card'); + const subsectionDropdownButton = await within(firstSubsection).findByTestId('subsection-card-header__menu-button'); + + subsection.isTimeLimited = expectedRequestData.metadata.is_time_limited; + subsection.defaultTimeLimitMinutes = expectedRequestData.metadata.default_time_limit_minutes; + subsection.isProctoredExam = expectedRequestData.metadata.is_proctored_enabled; + subsection.isPracticeExam = expectedRequestData.metadata.is_practice_exam; + subsection.isOnboardingExam = expectedRequestData.metadata.is_onboarding_exam; + subsection.examReviewRules = expectedRequestData.metadata.exam_review_rules; + section.childInfo.children[0] = subsection; + axiosMock + .onGet(getXBlockApiUrl(section.id)) + .reply(200, section); + + fireEvent.click(subsectionDropdownButton); + const configureBtn = await within(firstSubsection).findByTestId('subsection-card-header__menu-configure-button'); + fireEvent.click(configureBtn); + + // update fields + let configureModal = await findByTestId('configure-modal'); + let advancedTab = await within(configureModal).findByRole( + 'tab', + { name: configureModalMessages.advancedTabTitle.defaultMessage }, + ); + // visibility tab + const visibilityTab = await within(configureModal).findByRole('tab', { name: configureModalMessages.visibilityTabTitle.defaultMessage }); + fireEvent.click(visibilityTab); + const visibilityRadioButtons = await within(configureModal).findAllByRole('radio'); + fireEvent.click(visibilityRadioButtons[4]); + + // advancedTab + fireEvent.click(advancedTab); + let radioButtons = await within(configureModal).findAllByRole('radio'); + fireEvent.click(radioButtons[3]); + let hoursWrapper = await within(configureModal).findByTestId('advanced-tab-hours-picker-wrapper'); + let hours = await within(hoursWrapper).findByPlaceholderText('HH:MM'); + fireEvent.change(hours, { target: { value: '00:30' } }); + + // rules box should not be visible + expect(within(configureModal).queryByLabelText( + configureModalMessages.reviewRulesLabel.defaultMessage, + )).not.toBeInTheDocument(); + + const saveButton = await within(configureModal).findByTestId('configure-save-button'); + await act(async () => fireEvent.click(saveButton)); + + // verify request + expect(axiosMock.history.post.length).toBe(1); + expect(axiosMock.history.post[0].data).toBe(JSON.stringify(expectedRequestData)); + + // reopen modal and check values + await act(async () => fireEvent.click(subsectionDropdownButton)); + await act(async () => fireEvent.click(configureBtn)); + + configureModal = await findByTestId('configure-modal'); + advancedTab = await within(configureModal).findByRole('tab', { name: configureModalMessages.advancedTabTitle.defaultMessage }); + fireEvent.click(advancedTab); + radioButtons = await within(configureModal).findAllByRole('radio'); + expect(radioButtons[0]).toHaveProperty('checked', false); + expect(radioButtons[1]).toHaveProperty('checked', false); + expect(radioButtons[2]).toHaveProperty('checked', false); + expect(radioButtons[3]).toHaveProperty('checked', true); + hoursWrapper = await within(configureModal).findByTestId('advanced-tab-hours-picker-wrapper'); + hours = await within(hoursWrapper).findByPlaceholderText('HH:MM'); + expect(hours).toHaveValue('00:30'); + }); + + it('check onboarding proctoring settings in configure modal', async () => { + const { + findAllByTestId, + findByTestId, + } = render(); + const section = cloneDeep(courseOutlineIndexMock.courseStructure.childInfo.children[0]); + const [, subsection] = section.childInfo.children; + const expectedRequestData = { + publish: 'republish', + graderType: 'notgraded', + isPrereq: true, + prereqMinScore: 100, + prereqMinCompletion: 100, + metadata: { + visible_to_staff_only: null, + due: '', + hide_after_due: false, + show_correctness: 'past_due', + is_practice_exam: false, + is_time_limited: true, + is_proctored_enabled: true, + exam_review_rules: '', + default_time_limit_minutes: 30, + is_onboarding_exam: true, + start: '2013-02-05T05:00:00Z', + }, + }; + + axiosMock + .onPost(getCourseItemApiUrl(subsection.id), expectedRequestData) + .reply(200, { dummy: 'value' }); + + const [currentSection] = await findAllByTestId('section-card'); + const [, secondSubsection] = await within(currentSection).findAllByTestId('subsection-card'); + const subsectionDropdownButton = await within(secondSubsection).findByTestId('subsection-card-header__menu-button'); + + subsection.isTimeLimited = expectedRequestData.metadata.is_time_limited; + subsection.defaultTimeLimitMinutes = expectedRequestData.metadata.default_time_limit_minutes; + subsection.isProctoredExam = expectedRequestData.metadata.is_proctored_enabled; + subsection.isPracticeExam = expectedRequestData.metadata.is_practice_exam; + subsection.isOnboardingExam = expectedRequestData.metadata.is_onboarding_exam; + subsection.examReviewRules = expectedRequestData.metadata.exam_review_rules; + section.childInfo.children[1] = subsection; + axiosMock + .onGet(getXBlockApiUrl(section.id)) + .reply(200, section); + + fireEvent.click(subsectionDropdownButton); + const configureBtn = await within(secondSubsection).findByTestId('subsection-card-header__menu-configure-button'); + fireEvent.click(configureBtn); + + // update fields + let configureModal = await findByTestId('configure-modal'); + // visibility tab + const visibilityTab = await within(configureModal).findByRole('tab', { name: configureModalMessages.visibilityTabTitle.defaultMessage }); + fireEvent.click(visibilityTab); + const visibilityRadioButtons = await within(configureModal).findAllByRole('radio'); + fireEvent.click(visibilityRadioButtons[5]); + + // advancedTab + let advancedTab = await within(configureModal).findByRole( + 'tab', + { name: configureModalMessages.advancedTabTitle.defaultMessage }, + ); + fireEvent.click(advancedTab); + let radioButtons = await within(configureModal).findAllByRole('radio'); + fireEvent.click(radioButtons[3]); + let hoursWrapper = await within(configureModal).findByTestId('advanced-tab-hours-picker-wrapper'); + let hours = await within(hoursWrapper).findByPlaceholderText('HH:MM'); + fireEvent.change(hours, { target: { value: '00:30' } }); + + // rules box should not be visible + expect(within(configureModal).queryByLabelText( + configureModalMessages.reviewRulesLabel.defaultMessage, + )).not.toBeInTheDocument(); + + const saveButton = await within(configureModal).findByTestId('configure-save-button'); + await act(async () => fireEvent.click(saveButton)); + + // verify request + expect(axiosMock.history.post.length).toBe(1); + expect(axiosMock.history.post[0].data).toBe(JSON.stringify(expectedRequestData)); + + // reopen modal and check values + await act(async () => fireEvent.click(subsectionDropdownButton)); + await act(async () => fireEvent.click(configureBtn)); + + configureModal = await findByTestId('configure-modal'); + advancedTab = await within(configureModal).findByRole('tab', { name: configureModalMessages.advancedTabTitle.defaultMessage }); + fireEvent.click(advancedTab); + radioButtons = await within(configureModal).findAllByRole('radio'); + expect(radioButtons[0]).toHaveProperty('checked', false); + expect(radioButtons[1]).toHaveProperty('checked', false); + expect(radioButtons[2]).toHaveProperty('checked', false); + expect(radioButtons[3]).toHaveProperty('checked', true); + hoursWrapper = await within(configureModal).findByTestId('advanced-tab-hours-picker-wrapper'); + hours = await within(hoursWrapper).findByPlaceholderText('HH:MM'); + expect(hours).toHaveValue('00:30'); + }); + + it('check no special exam setting in configure modal', async () => { + const { + findAllByTestId, + findByTestId, + } = render(); + const section = cloneDeep(courseOutlineIndexMock.courseStructure.childInfo.children[1]); + const [subsection] = section.childInfo.children; + const expectedRequestData = { + publish: 'republish', + graderType: 'notgraded', + prereqMinScore: 100, + prereqMinCompletion: 100, + metadata: { + visible_to_staff_only: null, + due: '', + hide_after_due: false, + show_correctness: 'always', + is_practice_exam: false, + is_time_limited: false, + is_proctored_enabled: false, + exam_review_rules: '', + default_time_limit_minutes: 0, + is_onboarding_exam: false, + start: '1970-01-01T05:00:00Z', + }, + }; + + axiosMock + .onPost(getCourseItemApiUrl(subsection.id), expectedRequestData) + .reply(200, { dummy: 'value' }); + + const [, currentSection] = await findAllByTestId('section-card'); + const [subsectionElement] = await within(currentSection).findAllByTestId('subsection-card'); + const subsectionDropdownButton = await within(subsectionElement).findByTestId('subsection-card-header__menu-button'); + + subsection.isTimeLimited = expectedRequestData.metadata.is_time_limited; + subsection.defaultTimeLimitMinutes = expectedRequestData.metadata.default_time_limit_minutes; + subsection.isProctoredExam = expectedRequestData.metadata.is_proctored_enabled; + subsection.isPracticeExam = expectedRequestData.metadata.is_practice_exam; + subsection.isOnboardingExam = expectedRequestData.metadata.is_onboarding_exam; + subsection.examReviewRules = expectedRequestData.metadata.exam_review_rules; + section.childInfo.children[0] = subsection; + axiosMock + .onGet(getXBlockApiUrl(section.id)) + .reply(200, section); + + fireEvent.click(subsectionDropdownButton); + const configureBtn = await within(subsectionElement).findByTestId('subsection-card-header__menu-configure-button'); + fireEvent.click(configureBtn); + + // update fields + let configureModal = await findByTestId('configure-modal'); + + // advancedTab + let advancedTab = await within(configureModal).findByRole( + 'tab', + { name: configureModalMessages.advancedTabTitle.defaultMessage }, + ); + fireEvent.click(advancedTab); + let radioButtons = await within(configureModal).findAllByRole('radio'); + fireEvent.click(radioButtons[0]); + + // time box should not be visible + expect(within(configureModal).queryByLabelText( + configureModalMessages.timeAllotted.defaultMessage, + )).not.toBeInTheDocument(); + + // rules box should not be visible + expect(within(configureModal).queryByLabelText( + configureModalMessages.reviewRulesLabel.defaultMessage, + )).not.toBeInTheDocument(); + + const saveButton = await within(configureModal).findByTestId('configure-save-button'); + await act(async () => fireEvent.click(saveButton)); + + // verify request + expect(axiosMock.history.post.length).toBe(1); + expect(axiosMock.history.post[0].data).toBe(JSON.stringify(expectedRequestData)); + + // reopen modal and check values + await act(async () => fireEvent.click(subsectionDropdownButton)); + await act(async () => fireEvent.click(configureBtn)); + + configureModal = await findByTestId('configure-modal'); + advancedTab = await within(configureModal).findByRole('tab', { name: configureModalMessages.advancedTabTitle.defaultMessage }); + fireEvent.click(advancedTab); + radioButtons = await within(configureModal).findAllByRole('radio'); + expect(radioButtons[0]).toHaveProperty('checked', true); + expect(radioButtons[1]).toHaveProperty('checked', false); + expect(radioButtons[2]).toHaveProperty('checked', false); + expect(radioButtons[3]).toHaveProperty('checked', false); + }); + it('check configure modal for unit', async () => { const { findAllByTestId, findByTestId } = render(); const section = courseOutlineIndexMock.courseStructure.childInfo.children[0]; @@ -839,6 +1299,7 @@ describe('', () => { }, ], selectedPartitionIndex: 0, + selectedGroupsLabel: '', }; subsection.childInfo.children[0] = unit; section.childInfo.children[0] = subsection; @@ -1308,7 +1769,6 @@ describe('', () => { }); it('check that drag handle is not visible for non-draggable sections', async () => { - cleanup(); axiosMock .onGet(getCourseOutlineIndexApiUrl(courseId)) .reply(200, { diff --git a/src/course-outline/__mocks__/courseOutlineIndex.js b/src/course-outline/__mocks__/courseOutlineIndex.js index 2aa188c79..0969e5717 100644 --- a/src/course-outline/__mocks__/courseOutlineIndex.js +++ b/src/course-outline/__mocks__/courseOutlineIndex.js @@ -61,7 +61,7 @@ module.exports = { highlightsEnabled: true, highlightsPreviewOnly: false, highlightsDocUrl: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/latest/developing_course/course_sections.html#set-section-highlights-for-weekly-course-highlight-messages', - enableProctoredExams: false, + enableProctoredExams: true, createZendeskTickets: true, enableTimedExams: true, childInfo: { @@ -152,6 +152,11 @@ module.exports = { due: null, relativeWeeksDue: null, format: null, + isPrereq: false, + prereqs: [{ + blockDisplayName: 'Sample Subsection', + blockUsageKey: 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@7f75de8dcc261249250b71925f49810f', + }], courseGraders: [ 'Homework', 'Exam', @@ -381,10 +386,11 @@ module.exports = { is_practice_exam: false, is_onboarding_exam: false, is_time_limited: false, + isPrereq: true, exam_review_rules: '', default_time_limit_minutes: null, proctoring_exam_configuration_link: null, - supports_onboarding: false, + supports_onboarding: true, show_review_rules: true, child_info: { category: 'vertical', @@ -571,12 +577,12 @@ module.exports = { ], showCorrectness: 'always', hideAfterDue: false, - isProctoredExam: false, + isProctoredExam: true, wasExamEverLinkedWithExternal: false, onlineProctoringRules: '', isPracticeExam: false, isOnboardingExam: false, - isTimeLimited: false, + isTimeLimited: true, examReviewRules: '', defaultTimeLimitMinutes: null, proctoringExamConfigurationLink: null, @@ -3050,7 +3056,7 @@ module.exports = { languageCode: 'en', lmsLink: '//localhost:18000/courses/course-v1:edx+101+y76/jump_to/block-v1:edx+101+y76+type@course+block@course', mfeProctoredExamSettingsUrl: '', - notificationDismissUrl: '/course_notifications/course-v1:edx+101+y76/2', + notificationDismissUrl: '', proctoringErrors: [], reindexLink: '/course/course-v1:edx+101+y76/search_reindex', rerunNotificationId: 2, diff --git a/src/course-outline/card-header/BaseTitleWithStatusBadge.jsx b/src/course-outline/card-header/BaseTitleWithStatusBadge.jsx deleted file mode 100644 index e1cc504f6..000000000 --- a/src/course-outline/card-header/BaseTitleWithStatusBadge.jsx +++ /dev/null @@ -1,43 +0,0 @@ -import React from 'react'; -import PropTypes from 'prop-types'; -import { useIntl } from '@edx/frontend-platform/i18n'; -import { Icon, Truncate } from '@edx/paragon'; -import classNames from 'classnames'; -import { ITEM_BADGE_STATUS } from '../constants'; -import { getItemStatusBadgeContent } from '../utils'; -import messages from './messages'; - -const BaseTitleWithStatusBadge = ({ - title, - status, - namePrefix, -}) => { - const intl = useIntl(); - const { badgeTitle, badgeIcon } = getItemStatusBadgeContent(status, messages, intl); - - return ( - <> - {title} - {badgeTitle && ( -
- {badgeIcon && ( - - )} - {badgeTitle} -
- )} - - ); -}; - -BaseTitleWithStatusBadge.propTypes = { - title: PropTypes.string.isRequired, - status: PropTypes.string.isRequired, - namePrefix: PropTypes.string.isRequired, -}; - -export default BaseTitleWithStatusBadge; diff --git a/src/course-outline/card-header/CardHeader.jsx b/src/course-outline/card-header/CardHeader.jsx index 7ff6213fc..0a749e7a8 100644 --- a/src/course-outline/card-header/CardHeader.jsx +++ b/src/course-outline/card-header/CardHeader.jsx @@ -5,6 +5,7 @@ import { useSearchParams } from 'react-router-dom'; import { Dropdown, Form, + Hyperlink, Icon, IconButton, } from '@edx/paragon'; @@ -16,6 +17,7 @@ import { import { useEscapeClick } from '../../hooks'; import { ITEM_BADGE_STATUS } from '../constants'; import { scrollToElement } from '../utils'; +import CardStatus from './CardStatus'; import messages from './messages'; const CardHeader = ({ @@ -41,6 +43,11 @@ const CardHeader = ({ actions, enableCopyPasteUnits, isVertical, + isSequential, + proctoringExamConfigurationLink, + discussionEnabled, + discussionsSettings, + parentInfo, }) => { const intl = useIntl(); const [searchParams] = useSearchParams(); @@ -61,6 +68,17 @@ const CardHeader = ({ } }, []); + const showDiscussionsEnabledBadge = ( + isVertical + && !parentInfo?.isTimeLimited + && discussionEnabled + && discussionsSettings?.providerType === 'openedx' + && ( + discussionsSettings?.enableGradedUnits + || (!discussionsSettings?.enableGradedUnits && !parentInfo.graded) + ) + ); + useEscapeClick({ onEscape: () => { setTitleValue(title); @@ -76,7 +94,7 @@ const CardHeader = ({ ref={cardHeaderRef} > {isFormOpen ? ( - + e && e.focus()} @@ -94,16 +112,20 @@ const CardHeader = ({ /> ) : ( - titleComponent - )} -
- {!isFormOpen && ( + <> + {titleComponent} + + )} +
+ {(isVertical || isSequential) && ( + )} + {isSequential && proctoringExamConfigurationLink && ( + + {intl.formatMessage(messages.menuProctoringLinkText)} + + )} { const titleComponent = ( - - + /> ); return render( @@ -80,9 +76,8 @@ describe('', () => { const { findByText, findByTestId, queryByTestId } = renderComponent(); expect(await findByText(cardHeaderProps.title)).toBeInTheDocument(); - expect(await findByTestId('section-card-header__expanded-btn')).toBeInTheDocument(); - expect(await findByTestId('section-card-header__badge-status')).toBeInTheDocument(); - expect(await findByTestId('section-card-header__menu')).toBeInTheDocument(); + expect(await findByTestId('subsection-card-header__expanded-btn')).toBeInTheDocument(); + expect(await findByTestId('subsection-card-header__menu')).toBeInTheDocument(); await waitFor(() => { expect(queryByTestId('edit field')).not.toBeInTheDocument(); }); @@ -120,25 +115,25 @@ describe('', () => { expect(await findByText(messages.statusBadgeDraft.defaultMessage)).toBeInTheDocument(); }); - it('check publish menu item is disabled when section status is live or published not live and it has no changes', async () => { + it('check publish menu item is disabled when subsection status is live or published not live and it has no changes', async () => { const { findByText, findByTestId } = renderComponent({ ...cardHeaderProps, status: ITEM_BADGE_STATUS.publishedNotLive, }); - const menuButton = await findByTestId('section-card-header__menu-button'); + const menuButton = await findByTestId('subsection-card-header__menu-button'); fireEvent.click(menuButton); expect(await findByText(messages.menuPublish.defaultMessage)).toHaveAttribute('aria-disabled', 'true'); }); - it('check publish menu item is enabled when section status is live or published not live and it has changes', async () => { + it('check publish menu item is enabled when subsection status is live or published not live and it has changes', async () => { const { findByText, findByTestId } = renderComponent({ ...cardHeaderProps, status: ITEM_BADGE_STATUS.publishedNotLive, hasChanges: true, }); - const menuButton = await findByTestId('section-card-header__menu-button'); + const menuButton = await findByTestId('subsection-card-header__menu-button'); fireEvent.click(menuButton); expect(await findByText(messages.menuPublish.defaultMessage)).not.toHaveAttribute('aria-disabled'); }); @@ -146,7 +141,7 @@ describe('', () => { it('calls handleExpanded when button is clicked', async () => { const { findByTestId } = renderComponent(); - const expandButton = await findByTestId('section-card-header__expanded-btn'); + const expandButton = await findByTestId('subsection-card-header__expanded-btn'); fireEvent.click(expandButton); expect(onExpandMock).toHaveBeenCalled(); }); @@ -154,11 +149,9 @@ describe('', () => { it('calls onClickMenuButton when menu is clicked', async () => { const { findByTestId } = renderComponent(); - const menuButton = await findByTestId('section-card-header__menu-button'); - fireEvent.click(menuButton); - waitFor(() => { - expect(onClickMenuButtonMock).toHaveBeenCalled(); - }); + const menuButton = await findByTestId('subsection-card-header__menu-button'); + await act(async () => fireEvent.click(menuButton)); + expect(onClickMenuButtonMock).toHaveBeenCalled(); }); it('calls onClickPublish when item is clicked', async () => { @@ -167,24 +160,20 @@ describe('', () => { status: ITEM_BADGE_STATUS.draft, }); - const menuButton = await findByTestId('section-card-header__menu-button'); + const menuButton = await findByTestId('subsection-card-header__menu-button'); fireEvent.click(menuButton); const publishMenuItem = await findByText(messages.menuPublish.defaultMessage); - fireEvent.click(publishMenuItem); - waitFor(() => { - expect(onClickPublishMock).toHaveBeenCalled(); - }); + await act(async () => fireEvent.click(publishMenuItem)); + expect(onClickPublishMock).toHaveBeenCalled(); }); it('calls onClickEdit when the button is clicked', async () => { const { findByTestId } = renderComponent(); - const editButton = await findByTestId('section-edit-button'); - fireEvent.click(editButton); - waitFor(() => { - expect(onClickEditMock).toHaveBeenCalled(); - }); + const editButton = await findByTestId('subsection-edit-button'); + await act(async () => fireEvent.click(editButton)); + expect(onClickEditMock).toHaveBeenCalled(); }); it('check is field visible when isFormOpen is true', async () => { @@ -193,9 +182,9 @@ describe('', () => { isFormOpen: true, }); - expect(await findByTestId('section-edit-field')).toBeInTheDocument(); + expect(await findByTestId('subsection-edit-field')).toBeInTheDocument(); waitFor(() => { - expect(queryByTestId('section-card-header__expanded-btn')).not.toBeInTheDocument(); + expect(queryByTestId('subsection-card-header__expanded-btn')).not.toBeInTheDocument(); expect(queryByTestId('edit-button')).not.toBeInTheDocument(); }); }); @@ -207,32 +196,59 @@ describe('', () => { isDisabledEditField: true, }); - expect(await findByTestId('section-edit-field')).toBeDisabled(); + expect(await findByTestId('subsection-edit-field')).toBeDisabled(); }); it('calls onClickDelete when item is clicked', async () => { const { findByText, findByTestId } = renderComponent(); - const menuButton = await findByTestId('section-card-header__menu-button'); - fireEvent.click(menuButton); - + const menuButton = await findByTestId('subsection-card-header__menu-button'); + await act(async () => fireEvent.click(menuButton)); const deleteMenuItem = await findByText(messages.menuDelete.defaultMessage); - fireEvent.click(deleteMenuItem); - waitFor(() => { - expect(onClickDeleteMock).toHaveBeenCalledTimes(1); - }); + await act(async () => fireEvent.click(deleteMenuItem)); + expect(onClickDeleteMock).toHaveBeenCalledTimes(1); }); it('calls onClickDuplicate when item is clicked', async () => { const { findByText, findByTestId } = renderComponent(); - const menuButton = await findByTestId('section-card-header__menu-button'); + const menuButton = await findByTestId('subsection-card-header__menu-button'); fireEvent.click(menuButton); const duplicateMenuItem = await findByText(messages.menuDuplicate.defaultMessage); fireEvent.click(duplicateMenuItem); - waitFor(() => { - expect(onClickDuplicateMock).toHaveBeenCalled(); + await act(async () => fireEvent.click(duplicateMenuItem)); + expect(onClickDuplicateMock).toHaveBeenCalled(); + }); + + it('check if proctoringExamConfigurationLink is visible', async () => { + const { findByText, findByTestId } = renderComponent({ + ...cardHeaderProps, + proctoringExamConfigurationLink: 'https://localhost:8000/', + isSequential: true, }); + + const menuButton = await findByTestId('subsection-card-header__menu-button'); + await act(async () => fireEvent.click(menuButton)); + + expect(await findByText(messages.menuProctoringLinkText.defaultMessage)).toBeInTheDocument(); + }); + + it('check if discussion enabled badge is visible', async () => { + const { queryByText } = renderComponent({ + ...cardHeaderProps, + isVertical: true, + discussionEnabled: true, + discussionsSettings: { + providerType: 'openedx', + enableGradedUnits: true, + }, + parentInfo: { + isTimeLimited: false, + graded: false, + }, + }); + + expect(queryByText(messages.discussionEnabledBadgeText.defaultMessage)).toBeInTheDocument(); }); }); diff --git a/src/course-outline/card-header/CardStatus.jsx b/src/course-outline/card-header/CardStatus.jsx new file mode 100644 index 000000000..b5dc3560b --- /dev/null +++ b/src/course-outline/card-header/CardStatus.jsx @@ -0,0 +1,40 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { useIntl } from '@edx/frontend-platform/i18n'; +import classNames from 'classnames'; +import { ITEM_BADGE_STATUS } from '../constants'; +import { getItemStatusBadgeContent } from '../utils'; +import messages from './messages'; +import StatusBadge from './StatusBadge'; + +const CardStatus = ({ + status, + showDiscussionsEnabledBadge, +}) => { + const intl = useIntl(); + const { badgeTitle, badgeIcon } = getItemStatusBadgeContent(status, messages, intl); + + return ( + <> + {showDiscussionsEnabledBadge && ( + + )} + {badgeTitle && ( + + )} + + ); +}; + +CardStatus.propTypes = { + status: PropTypes.string.isRequired, + showDiscussionsEnabledBadge: PropTypes.bool.isRequired, +}; + +export default CardStatus; diff --git a/src/course-outline/card-header/StatusBadge.jsx b/src/course-outline/card-header/StatusBadge.jsx new file mode 100644 index 000000000..ead29c990 --- /dev/null +++ b/src/course-outline/card-header/StatusBadge.jsx @@ -0,0 +1,42 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { Icon } from '@edx/paragon'; + +const StatusBadge = ({ + text, + icon, + iconClassName, +}) => { + if (text) { + return ( +
+ {icon && ( + + )} + {text} +
+ ); + } + return null; +}; + +StatusBadge.defaultProps = { + text: '', + icon: '', + iconClassName: '', +}; + +StatusBadge.propTypes = { + text: PropTypes.string, + icon: PropTypes.string, + iconClassName: PropTypes.string, +}; + +export default StatusBadge; diff --git a/src/course-outline/card-header/TitleButton.jsx b/src/course-outline/card-header/TitleButton.jsx index 44e891a41..8f17b0e5f 100644 --- a/src/course-outline/card-header/TitleButton.jsx +++ b/src/course-outline/card-header/TitleButton.jsx @@ -4,6 +4,7 @@ import { Button, OverlayTrigger, Tooltip, + Truncate, } from '@edx/paragon'; import { ArrowDropDown as ArrowDownIcon, @@ -12,21 +13,20 @@ import { import messages from './messages'; const TitleButton = ({ + title, isExpanded, onTitleClick, namePrefix, - children, }) => { const intl = useIntl(); const titleTooltipMessage = intl.formatMessage(messages.expandTooltip); return ( {titleTooltipMessage} @@ -39,21 +39,17 @@ const TitleButton = ({ className="item-card-header__title-btn" onClick={onTitleClick} > - {children} + {title} ); }; -TitleButton.defaultProps = { - children: null, -}; - TitleButton.propTypes = { + title: PropTypes.string.isRequired, isExpanded: PropTypes.bool.isRequired, onTitleClick: PropTypes.func.isRequired, namePrefix: PropTypes.string.isRequired, - children: PropTypes.node, }; export default TitleButton; diff --git a/src/course-outline/card-header/TitleLink.jsx b/src/course-outline/card-header/TitleLink.jsx index 4a27d11cd..bc7c93d93 100644 --- a/src/course-outline/card-header/TitleLink.jsx +++ b/src/course-outline/card-header/TitleLink.jsx @@ -1,11 +1,11 @@ import PropTypes from 'prop-types'; import { Link } from 'react-router-dom'; -import { Button } from '@edx/paragon'; +import { Button, Truncate } from '@edx/paragon'; const TitleLink = ({ + title, titleLink, namePrefix, - children, }) => ( ); -TitleLink.defaultProps = { - children: null, -}; - TitleLink.propTypes = { + title: PropTypes.string.isRequired, titleLink: PropTypes.string.isRequired, namePrefix: PropTypes.string.isRequired, - children: PropTypes.node, }; export default TitleLink; diff --git a/src/course-outline/card-header/messages.js b/src/course-outline/card-header/messages.js index 654d8fe49..d9f250970 100644 --- a/src/course-outline/card-header/messages.js +++ b/src/course-outline/card-header/messages.js @@ -61,6 +61,18 @@ const messages = defineMessages({ id: 'course-authoring.course-outline.card.menu.delete', defaultMessage: 'Copy to clipboard', }, + menuProctoringLinkText: { + id: 'course-authoring.course-outline.card.menu.proctoring-settings', + defaultMessage: 'Proctoring settings', + }, + proctoringLinkTooltip: { + id: 'course-authoring.course-outline.card.menu.proctoring-settings-tooltip', + defaultMessage: 'Proctoring settings', + }, + discussionEnabledBadgeText: { + id: 'course-authoring.course-outline.card.badge.discussionEnabled', + defaultMessage: 'Discussions enabled', + }, }); export default messages; diff --git a/src/course-outline/configure-modal/AdvancedTab.jsx b/src/course-outline/configure-modal/AdvancedTab.jsx index 4a8c9771a..c67ee8847 100644 --- a/src/course-outline/configure-modal/AdvancedTab.jsx +++ b/src/course-outline/configure-modal/AdvancedTab.jsx @@ -1,16 +1,49 @@ import React, { useState } from 'react'; import PropTypes from 'prop-types'; import moment from 'moment'; -import { Form } from '@edx/paragon'; +import { Alert, Form, Hyperlink } from '@edx/paragon'; +import { + Warning as WarningIcon, +} from '@edx/paragon/icons'; import { FormattedMessage, injectIntl } from '@edx/frontend-platform/i18n'; import messages from './messages'; +import PrereqSettings from './PrereqSettings'; + const AdvancedTab = ({ - isTimeLimited, - setIsTimeLimited, - defaultTimeLimit, - setDefaultTimeLimit, + values, + setFieldValue, + prereqs, + releasedToStudents, + wasExamEverLinkedWithExternal, + enableProctoredExams, + supportsOnboarding, + wasProctoredExam, + showReviewRules, + onlineProctoringRules, }) => { + const { + isTimeLimited, + isProctoredExam, + isOnboardingExam, + isPracticeExam, + defaultTimeLimitMinutes, + examReviewRules, + } = values; + let examTypeValue = 'none'; + + if (isTimeLimited && isProctoredExam) { + if (isOnboardingExam) { + examTypeValue = 'onboardingExam'; + } else if (isPracticeExam) { + examTypeValue = 'practiceExam'; + } else { + examTypeValue = 'proctoredExam'; + } + } else if (isTimeLimited) { + examTypeValue = 'timed'; + } + const formatHour = (hour) => { const hh = Math.floor(hour / 60); const mm = hour % 60; @@ -31,14 +64,35 @@ const AdvancedTab = ({ return `${hhs}:${mms}`; }; - const [timeLimit, setTimeLimit] = useState(formatHour(defaultTimeLimit)); + const [timeLimit, setTimeLimit] = useState(formatHour(defaultTimeLimitMinutes)); + const showReviewRulesDiv = showReviewRules && isProctoredExam && !isPracticeExam && !isOnboardingExam; const handleChange = (e) => { if (e.target.value === 'timed') { - setIsTimeLimited(true); + setFieldValue('isTimeLimited', true); + setFieldValue('isOnboardingExam', false); + setFieldValue('isPracticeExam', false); + setFieldValue('isProctoredExam', false); + } else if (e.target.value === 'onboardingExam') { + setFieldValue('isOnboardingExam', true); + setFieldValue('isProctoredExam', true); + setFieldValue('isTimeLimited', true); + setFieldValue('isPracticeExam', false); + } else if (e.target.value === 'practiceExam') { + setFieldValue('isPracticeExam', true); + setFieldValue('isProctoredExam', true); + setFieldValue('isTimeLimited', true); + setFieldValue('isOnboardingExam', false); + } else if (e.target.value === 'proctoredExam') { + setFieldValue('isProctoredExam', true); + setFieldValue('isTimeLimited', true); + setFieldValue('isOnboardingExam', false); + setFieldValue('isPracticeExam', false); } else { - setDefaultTimeLimit(null); - setIsTimeLimited(false); + setFieldValue('isTimeLimited', false); + setFieldValue('isOnboardingExam', false); + setFieldValue('isPracticeExam', false); + setFieldValue('isProctoredExam', false); } }; @@ -48,11 +102,29 @@ const AdvancedTab = ({ value = value.trim(); if (value && valid) { const minutes = moment.duration(value).asMinutes(); - setDefaultTimeLimit(minutes); + setFieldValue('defaultTimeLimitMinutes', minutes); } setTimeLimit(value); }; + const renderAlerts = () => { + const proctoredExamLockedIn = releasedToStudents && wasExamEverLinkedWithExternal; + return ( + <> + {proctoredExamLockedIn && !wasProctoredExam && ( + + + + )} + {proctoredExamLockedIn && wasProctoredExam && ( + + + + )} + + ); + }; + return ( <>
@@ -60,15 +132,47 @@ const AdvancedTab = ({ + {renderAlerts()} - + } + controlClassName="mw-1-25rem" + > - + {enableProctoredExams && ( + <> + } + controlClassName="mw-1-25rem" + > + + + {supportsOnboarding ? ( + } + value="onboardingExam" + controlClassName="mw-1-25rem" + > + + + ) : ( + } + > + + + )} + + )} { isTimeLimited && (
@@ -86,15 +190,87 @@ const AdvancedTab = ({
)} + { showReviewRulesDiv && ( +
+ + + + + setFieldValue('examReviewRules', e.target.value)} + value={examReviewRules} + as="textarea" + rows="3" + /> + + + { onlineProctoringRules ? ( + + + + ), + }} + /> + ) : ( + + )} + +
+ )} + ); }; +AdvancedTab.defaultProps = { + prereqs: [], + wasExamEverLinkedWithExternal: false, + enableProctoredExams: false, + supportsOnboarding: false, + wasProctoredExam: false, + showReviewRules: false, + onlineProctoringRules: '', +}; + AdvancedTab.propTypes = { - isTimeLimited: PropTypes.bool.isRequired, - setIsTimeLimited: PropTypes.func.isRequired, - defaultTimeLimit: PropTypes.number.isRequired, - setDefaultTimeLimit: PropTypes.func.isRequired, + 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 injectIntl(AdvancedTab); diff --git a/src/course-outline/configure-modal/BasicTab.jsx b/src/course-outline/configure-modal/BasicTab.jsx index 2f8a526dd..233010ade 100644 --- a/src/course-outline/configure-modal/BasicTab.jsx +++ b/src/course-outline/configure-modal/BasicTab.jsx @@ -6,18 +6,20 @@ import messages from './messages'; import { DatepickerControl, DATEPICKER_TYPES } from '../../generic/datepicker-control'; const BasicTab = ({ - releaseDate, - setReleaseDate, - isSubsection, - graderType, + values, + setFieldValue, courseGraders, - setGraderType, - dueDate, - setDueDate, + isSubsection, }) => { const intl = useIntl(); - const onChangeGraderType = (e) => setGraderType(e.target.value); + const { + releaseDate, + graderType, + dueDate, + } = values; + + const onChangeGraderType = (e) => setFieldValue('graderType', e.target.value); const createOptions = () => courseGraders.map((option) => ( @@ -34,14 +36,14 @@ const BasicTab = ({ value={releaseDate} label={intl.formatMessage(messages.releaseDate)} controlName="state-date" - onChange={setReleaseDate} + onChange={(val) => setFieldValue('releaseDate', val)} /> setFieldValue('releaseDate', val)} />
@@ -50,16 +52,20 @@ const BasicTab = ({

- - onChangeGraderType(value)} - data-testid="grader-type-select" - > - - {createOptions()} - + + + + + {createOptions()} + +
setFieldValue('dueDate', val)} data-testid="due-date-picker" /> setFieldValue('dueDate', val)} />
@@ -87,18 +93,14 @@ const BasicTab = ({ }; BasicTab.propTypes = { - releaseDate: PropTypes.string.isRequired, - setReleaseDate: PropTypes.func.isRequired, isSubsection: PropTypes.bool.isRequired, - graderType: PropTypes.string.isRequired, - setGraderType: PropTypes.func.isRequired, - dueDate: PropTypes.string, - setDueDate: PropTypes.func.isRequired, + values: PropTypes.shape({ + releaseDate: PropTypes.string.isRequired, + graderType: PropTypes.string.isRequired, + dueDate: PropTypes.string, + }).isRequired, courseGraders: PropTypes.arrayOf(PropTypes.string).isRequired, -}; - -BasicTab.defaultProps = { - dueDate: null, + setFieldValue: PropTypes.func.isRequired, }; export default injectIntl(BasicTab); diff --git a/src/course-outline/configure-modal/ConfigureModal.jsx b/src/course-outline/configure-modal/ConfigureModal.jsx index 5772faf20..4838949dc 100644 --- a/src/course-outline/configure-modal/ConfigureModal.jsx +++ b/src/course-outline/configure-modal/ConfigureModal.jsx @@ -1,20 +1,22 @@ /* eslint-disable import/named */ -import React, { useEffect, useState } from 'react'; +import React from 'react'; +import * as Yup from 'yup'; import PropTypes from 'prop-types'; import { useIntl } from '@edx/frontend-platform/i18n'; import { ModalDialog, Button, ActionRow, + Form, Tab, Tabs, - useCheckboxSetValues, } from '@edx/paragon'; import { useSelector } from 'react-redux'; +import { Formik } from 'formik'; import { VisibilityTypes } from '../../data/constants'; import { COURSE_BLOCK_NAMES } from '../constants'; -import { getCurrentItem } from '../data/selectors'; +import { getCurrentItem, getProctoredExamsFlag } from '../data/selectors'; import messages from './messages'; import BasicTab from './BasicTab'; import VisibilityTab from './VisibilityTab'; @@ -41,27 +43,26 @@ const ConfigureModal = ({ format, userPartitionInfo, ancestorHasStaffLock, + isPrereq, + prereqs, + prereq, + prereqMinScore, + prereqMinCompletion, + releasedToStudents, + wasExamEverLinkedWithExternal, + isProctoredExam, + isOnboardingExam, + isPracticeExam, + examReviewRules, + supportsOnboarding, + showReviewRules, + onlineProctoringRules, } = useSelector(getCurrentItem); + const enableProctoredExams = useSelector(getProctoredExamsFlag); - const [releaseDate, setReleaseDate] = useState(sectionStartDate); - const [isVisibleToStaffOnly, setIsVisibleToStaffOnly] = useState(visibilityState === VisibilityTypes.STAFF_ONLY); - - const [saveButtonDisabled, setSaveButtonDisabled] = useState(true); - const [graderType, setGraderType] = useState(format == null ? 'Not Graded' : format); - const [dueDateState, setDueDateState] = useState(due == null ? '' : due); - const [isTimeLimitedState, setIsTimeLimitedState] = useState(false); - const [defaultTimeLimitMin, setDefaultTimeLimitMin] = useState(defaultTimeLimitMinutes); - const [hideAfterDueState, setHideAfterDueState] = useState(hideAfterDue === undefined ? false : hideAfterDue); - const [showCorrectnessState, setShowCorrectnessState] = useState(false); - const isSubsection = category === COURSE_BLOCK_NAMES.sequential.id; - - /* TODO: The use of these useEffects needs to be updated to use Formik, please see, - * https://github.com/open-craft/frontend-app-course-authoring/pull/22#discussion_r1435957797 as reference. */ - // by default it is -1 i.e. accessible to all learners & staff - const [selectedPartitionIndex, setSelectedPartitionIndex] = useState(userPartitionInfo?.selectedPartitionIndex); const getSelectedGroups = () => { - if (selectedPartitionIndex >= 0) { - return userPartitionInfo?.selectablePartitions[selectedPartitionIndex] + if (userPartitionInfo?.selectedPartitionIndex >= 0) { + return userPartitionInfo?.selectablePartitions[userPartitionInfo?.selectedPartitionIndex] ?.groups .filter(({ selected }) => selected) .map(({ id }) => `${id}`) @@ -70,140 +71,126 @@ const ConfigureModal = ({ return []; }; - const [selectedGroups, { add, remove, set }] = useCheckboxSetValues([]); + const defaultPrereqScore = (val) => { + if (val === null || val === undefined) { + return 100; + } + return parseFloat(val); + }; - useEffect(() => { - setSelectedPartitionIndex(userPartitionInfo?.selectedPartitionIndex); - }, [userPartitionInfo]); + const initialValues = { + releaseDate: sectionStartDate, + isVisibleToStaffOnly: visibilityState === VisibilityTypes.STAFF_ONLY, + saveButtonDisabled: true, + graderType: format == null ? 'notgraded' : format, + dueDate: due == null ? '' : due, + isTimeLimited, + isProctoredExam, + isOnboardingExam, + isPracticeExam, + examReviewRules, + defaultTimeLimitMinutes, + hideAfterDue: hideAfterDue === undefined ? false : hideAfterDue, + showCorrectness, + isPrereq, + prereqUsageKey: prereq, + prereqMinScore: defaultPrereqScore(prereqMinScore), + prereqMinCompletion: defaultPrereqScore(prereqMinCompletion), + // by default it is -1 i.e. accessible to all learners & staff + selectedPartitionIndex: userPartitionInfo?.selectedPartitionIndex, + selectedGroups: getSelectedGroups(), + }; - useEffect(() => { - set(getSelectedGroups()); - }, [selectedPartitionIndex, userPartitionInfo]); + const validationSchema = Yup.object().shape({ + isTimeLimited: Yup.boolean(), + isProctoredExam: Yup.boolean(), + isPracticeExam: Yup.boolean(), + isOnboardingExam: Yup.boolean(), + examReviewRules: Yup.string(), + defaultTimeLimitMinutes: Yup.number().nullable(true), + hideAfterDueState: Yup.boolean(), + showCorrectness: Yup.string().required(), + isPrereq: Yup.boolean(), + prereqUsageKey: Yup.string().nullable(true), + prereqMinScore: Yup.number().min( + 0, + intl.formatMessage(messages.minScoreError), + ).max( + 100, + intl.formatMessage(messages.minScoreError), + ).nullable(true), + prereqMinCompletion: Yup.number().min( + 0, + intl.formatMessage(messages.minScoreError), + ).max( + 100, + intl.formatMessage(messages.minScoreError), + ).nullable(true), + selectedPartitionIndex: Yup.number().integer(), + selectedGroups: Yup.array().of(Yup.string()), + }); - useEffect(() => { - setReleaseDate(sectionStartDate); - }, [sectionStartDate]); + const isSubsection = category === COURSE_BLOCK_NAMES.sequential.id; - useEffect(() => { - setGraderType(format == null ? 'Not Graded' : format); - }, [format]); - - useEffect(() => { - setDueDateState(due == null ? '' : due); - }, [due]); - - useEffect(() => { - setIsTimeLimitedState(isTimeLimited); - }, [isTimeLimited]); - - useEffect(() => { - setDefaultTimeLimitMin(defaultTimeLimitMinutes); - }, [defaultTimeLimitMinutes]); - - useEffect(() => { - setHideAfterDueState(hideAfterDue === undefined ? false : hideAfterDue); - }, [hideAfterDue]); - - useEffect(() => { - setShowCorrectnessState(showCorrectness); - }, [showCorrectness]); - - useEffect(() => { - setIsVisibleToStaffOnly(visibilityState === VisibilityTypes.STAFF_ONLY); - }, [visibilityState]); - - useEffect(() => { - const visibilityUnchanged = isVisibleToStaffOnly === (visibilityState === VisibilityTypes.STAFF_ONLY); - const graderTypeUnchanged = graderType === (format == null ? 'Not Graded' : format); - const dueDateUnchanged = dueDateState === (due == null ? '' : due); - const hideAfterDueUnchanged = hideAfterDueState === (hideAfterDue === undefined ? false : hideAfterDue); - const selectedGroupsUnchanged = selectedGroups.sort().join(',') === getSelectedGroups().sort().join(','); - // handle the case of unrestricting access - const accessRestrictionUnchanged = selectedPartitionIndex !== -1 - || userPartitionInfo?.selectedPartitionIndex === -1; - setSaveButtonDisabled( - visibilityUnchanged - && releaseDate === sectionStartDate - && dueDateUnchanged - && isTimeLimitedState === isTimeLimited - && defaultTimeLimitMin === defaultTimeLimitMinutes - && hideAfterDueUnchanged - && showCorrectnessState === showCorrectness - && graderTypeUnchanged - && selectedGroupsUnchanged - && accessRestrictionUnchanged, - ); - }, [ - releaseDate, - isVisibleToStaffOnly, - dueDateState, - isTimeLimitedState, - defaultTimeLimitMin, - hideAfterDueState, - showCorrectnessState, - graderType, - selectedGroups, - ]); - - const handleSave = () => { + const handleSave = (data) => { const groupAccess = {}; switch (category) { case COURSE_BLOCK_NAMES.chapter.id: - onConfigureSubmit(isVisibleToStaffOnly, releaseDate); + onConfigureSubmit(data.isVisibleToStaffOnly, data.releaseDate); break; case COURSE_BLOCK_NAMES.sequential.id: onConfigureSubmit( - isVisibleToStaffOnly, - releaseDate, - graderType === 'Not Graded' ? 'notgraded' : graderType, - dueDateState, - isTimeLimitedState, - defaultTimeLimitMin, - hideAfterDueState, - showCorrectnessState, + data.isVisibleToStaffOnly, + data.releaseDate, + data.graderType, + data.dueDate, + data.isTimeLimited, + data.isProctoredExam, + data.isOnboardingExam, + data.isPracticeExam, + data.examReviewRules, + data.isTimeLimited ? data.defaultTimeLimitMinutes : 0, + data.hideAfterDue, + data.showCorrectness, + data.isPrereq, + data.prereqUsageKey, + data.prereqMinScore, + data.prereqMinCompletion, ); break; case COURSE_BLOCK_NAMES.vertical.id: // groupAccess should be {partitionId: [group1, group2]} or {} if selectedPartitionIndex === -1 - if (selectedPartitionIndex >= 0) { - const partitionId = userPartitionInfo.selectablePartitions[selectedPartitionIndex].id; - groupAccess[partitionId] = selectedGroups.map(g => parseInt(g, 10)); + if (data.selectedPartitionIndex >= 0) { + const partitionId = userPartitionInfo.selectablePartitions[data.selectedPartitionIndex].id; + groupAccess[partitionId] = data.selectedGroups.map(g => parseInt(g, 10)); } - onConfigureSubmit(isVisibleToStaffOnly, groupAccess); + onConfigureSubmit(data.isVisibleToStaffOnly, groupAccess); break; default: break; } }; - const renderModalBody = () => { + const renderModalBody = (values, setFieldValue) => { switch (category) { case COURSE_BLOCK_NAMES.chapter.id: return ( @@ -213,35 +200,33 @@ const ConfigureModal = ({ @@ -249,15 +234,10 @@ const ConfigureModal = ({ case COURSE_BLOCK_NAMES.vertical.id: return ( ); default: @@ -266,36 +246,51 @@ const ConfigureModal = ({ }; return ( - isOpen && ( - -
- - - {intl.formatMessage(messages.title, { title: displayName })} - - - - {renderModalBody(category)} - - - - - {intl.formatMessage(messages.cancelButton)} - - - - -
-
- ) + +
+ + + {intl.formatMessage(messages.title, { title: displayName })} + + + + {({ + values, handleSubmit, dirty, isValid, setFieldValue, + }) => ( + <> + + + {renderModalBody(values, setFieldValue)} + + + + + + {intl.formatMessage(messages.cancelButton)} + + + + + + )} + +
+
); }; diff --git a/src/course-outline/configure-modal/ConfigureModal.scss b/src/course-outline/configure-modal/ConfigureModal.scss index 3b8d04a9b..9a0eb6474 100644 --- a/src/course-outline/configure-modal/ConfigureModal.scss +++ b/src/course-outline/configure-modal/ConfigureModal.scss @@ -1,8 +1,14 @@ .configure-modal { - max-width: 33.6875rem; - .configure-modal__header { padding-top: 1.5rem; position: static; } + + .w-7rem { + width: 7.2rem; + } + + .mw-1-25rem { + min-width: 1.25rem; + } } diff --git a/src/course-outline/configure-modal/PrereqSettings.jsx b/src/course-outline/configure-modal/PrereqSettings.jsx new file mode 100644 index 000000000..e7170e503 --- /dev/null +++ b/src/course-outline/configure-modal/PrereqSettings.jsx @@ -0,0 +1,117 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { Form } from '@edx/paragon'; +import { FormattedMessage, injectIntl, useIntl } from '@edx/frontend-platform/i18n'; +import messages from './messages'; + +import FormikControl from '../../generic/FormikControl'; + +const PrereqSettings = ({ + values, + setFieldValue, + prereqs, +}) => { + const intl = useIntl(); + const { + isPrereq, + prereqUsageKey, + prereqMinScore, + prereqMinCompletion, + } = values; + + if (isPrereq === null || isPrereq === undefined) { + return null; + } + + const handleSelectChange = (e) => { + setFieldValue('prereqUsageKey', e.target.value); + }; + + const prereqSelectionForm = () => ( + <> +
+
+
+ + + + {intl.formatMessage(messages.prerequisiteSelectLabel)} + + + + {prereqs.map((prereqOption) => ( + + ))} + + + {prereqUsageKey && ( + <> + {intl.formatMessage(messages.minScoreLabel)}} + controlClassName="text-right" + controlClasses="w-7rem" + type="number" + trailingElement="%" + /> + {intl.formatMessage(messages.minCompletionLabel)}} + controlClassName="text-right" + controlClasses="w-7rem" + type="number" + trailingElement="%" + /> + + )} + + + ); + + const handleCheckboxChange = e => setFieldValue('isPrereq', e.target.checked); + + return ( + <> + {prereqs.length > 0 && prereqSelectionForm()} +
+
+ + + + + ); +}; + +PrereqSettings.defaultProps = { + prereqs: [], +}; + +PrereqSettings.propTypes = { + values: PropTypes.shape({ + isPrereq: PropTypes.bool, + prereqUsageKey: PropTypes.string, + prereqMinScore: PropTypes.number, + prereqMinCompletion: PropTypes.number, + }).isRequired, + prereqs: PropTypes.arrayOf(PropTypes.shape({ + blockUsageKey: PropTypes.string.isRequired, + blockDisplayName: PropTypes.string.isRequired, + })), + setFieldValue: PropTypes.func.isRequired, +}; + +export default injectIntl(PrereqSettings); diff --git a/src/course-outline/configure-modal/UnitTab.jsx b/src/course-outline/configure-modal/UnitTab.jsx index 452a4c672..83b85e68e 100644 --- a/src/course-outline/configure-modal/UnitTab.jsx +++ b/src/course-outline/configure-modal/UnitTab.jsx @@ -1,36 +1,33 @@ import React from 'react'; import PropTypes from 'prop-types'; import { Alert, Form } from '@edx/paragon'; -import { FormattedMessage, injectIntl, intlShape } from '@edx/frontend-platform/i18n'; +import { + FormattedMessage, injectIntl, useIntl, +} from '@edx/frontend-platform/i18n'; +import { Field } from 'formik'; + import messages from './messages'; const UnitTab = ({ - intl, - isVisibleToStaffOnly, - setIsVisibleToStaffOnly, + values, + setFieldValue, showWarning, userPartitionInfo, - setSelectedPartitionIndex, - selectedPartitionIndex, - selectedGroups, - add, - remove, }) => { + const intl = useIntl(); + const { + isVisibleToStaffOnly, + selectedPartitionIndex, + } = values; + const handleChange = (e) => { - setIsVisibleToStaffOnly(e.target.checked); + setFieldValue('isVisibleToStaffOnly', e.target.checked); }; const handleSelect = (e) => { - setSelectedPartitionIndex(parseInt(e.target.value, 10)); + setFieldValue('selectedPartitionIndex', parseInt(e.target.value, 10)); }; - const handleCheckBoxChange = e => { - if (e.target.checked) { - add(e.target.value); - } else { - remove(e.target.value); - } - }; return ( <>

@@ -39,7 +36,7 @@ const UnitTab = ({ {showWarning && ( - + )} @@ -71,16 +68,33 @@ const UnitTab = ({ {selectedPartitionIndex >= 0 && userPartitionInfo.selectablePartitions.length && ( - + - - {userPartitionInfo.selectablePartitions[selectedPartitionIndex].groups.map((group) => ({group.name}))} - + {userPartitionInfo.selectablePartitions[selectedPartitionIndex].groups.map((group) => ( + + + + {group.name} + + + ))} +
)} @@ -90,9 +104,14 @@ const UnitTab = ({ }; UnitTab.propTypes = { - intl: intlShape.isRequired, - isVisibleToStaffOnly: PropTypes.bool.isRequired, - setIsVisibleToStaffOnly: PropTypes.func.isRequired, + values: PropTypes.shape({ + isVisibleToStaffOnly: PropTypes.bool.isRequired, + selectedPartitionIndex: PropTypes.oneOfType([ + PropTypes.string, + PropTypes.number, + ]).isRequired, + }).isRequired, + setFieldValue: PropTypes.func.isRequired, showWarning: PropTypes.bool.isRequired, userPartitionInfo: PropTypes.shape({ selectablePartitions: PropTypes.arrayOf(PropTypes.shape({ @@ -106,20 +125,9 @@ UnitTab.propTypes = { name: PropTypes.string.isRequired, scheme: PropTypes.string.isRequired, }).isRequired).isRequired, - selectedGroupsLabel: PropTypes.string.isRequired, + selectedGroupsLabel: PropTypes.string, selectedPartitionIndex: PropTypes.number.isRequired, }).isRequired, - setSelectedPartitionIndex: PropTypes.func.isRequired, - selectedPartitionIndex: PropTypes.oneOfType([ - PropTypes.string, - PropTypes.number, - ]).isRequired, - selectedGroups: PropTypes.arrayOf(PropTypes.oneOfType([ - PropTypes.string, - PropTypes.number, - ])).isRequired, - add: PropTypes.func.isRequired, - remove: PropTypes.func.isRequired, }; export default injectIntl(UnitTab); diff --git a/src/course-outline/configure-modal/VisibilityTab.jsx b/src/course-outline/configure-modal/VisibilityTab.jsx index 9051ccece..7aa587ded 100644 --- a/src/course-outline/configure-modal/VisibilityTab.jsx +++ b/src/course-outline/configure-modal/VisibilityTab.jsx @@ -6,20 +6,23 @@ import messages from './messages'; import { COURSE_BLOCK_NAMES } from '../constants'; const VisibilityTab = ({ + values, + setFieldValue, category, - isVisibleToStaffOnly, - setIsVisibleToStaffOnly, showWarning, isSubsection, - hideAfterDue, - setHideAfterDue, - showCorrectness, - setShowCorrectness, }) => { const intl = useIntl(); const visibilityTitle = COURSE_BLOCK_NAMES[category]?.name; + + const { + isVisibleToStaffOnly, + hideAfterDue, + showCorrectness, + } = values; + const handleChange = (e) => { - setIsVisibleToStaffOnly(e.target.checked); + setFieldValue('isVisibleToStaffOnly', e.target.checked); }; const getVisibilityValue = () => { @@ -35,19 +38,19 @@ const VisibilityTab = ({ const visibilityChanged = (e) => { const selected = e.target.value; if (selected === 'hide') { - setIsVisibleToStaffOnly(true); - setHideAfterDue(false); + setFieldValue('isVisibleToStaffOnly', true); + setFieldValue('hideAfterDue', false); } else if (selected === 'hideDue') { - setIsVisibleToStaffOnly(false); - setHideAfterDue(true); + setFieldValue('isVisibleToStaffOnly', false); + setFieldValue('hideAfterDue', true); } else { - setIsVisibleToStaffOnly(false); - setHideAfterDue(false); + setFieldValue('isVisibleToStaffOnly', false); + setFieldValue('hideAfterDue', false); } }; const correctnessChanged = (e) => { - setShowCorrectness(e.target.value); + setFieldValue('showCorrectness', e.target.value); }; return ( @@ -77,6 +80,11 @@ const VisibilityTab = ({ + {showWarning && ( + + + + )}
) } - {showWarning && ( - <> -
- - - - - + {showWarning && !isSubsection && ( + + + )} ); }; VisibilityTab.propTypes = { + values: PropTypes.shape({ + isVisibleToStaffOnly: PropTypes.bool.isRequired, + hideAfterDue: PropTypes.bool.isRequired, + showCorrectness: PropTypes.string.isRequired, + }).isRequired, + setFieldValue: PropTypes.func.isRequired, category: PropTypes.string.isRequired, - isVisibleToStaffOnly: PropTypes.bool.isRequired, showWarning: PropTypes.bool.isRequired, - setIsVisibleToStaffOnly: PropTypes.func.isRequired, isSubsection: PropTypes.bool.isRequired, - hideAfterDue: PropTypes.bool.isRequired, - setHideAfterDue: PropTypes.func.isRequired, - showCorrectness: PropTypes.string.isRequired, - setShowCorrectness: PropTypes.func.isRequired, }; export default injectIntl(VisibilityTab); diff --git a/src/course-outline/configure-modal/messages.js b/src/course-outline/configure-modal/messages.js index 13d1d3964..316cbc0fb 100644 --- a/src/course-outline/configure-modal/messages.js +++ b/src/course-outline/configure-modal/messages.js @@ -9,6 +9,10 @@ const messages = defineMessages({ id: 'course-authoring.course-outline.configure-modal.basic-tab.title', defaultMessage: 'Basic', }, + notGradedTypeOption: { + id: 'course-authoring.course-outline.configure-modal.basic-tab.notGradedTypeOption', + defaultMessage: 'Not Graded', + }, releaseDateAndTime: { id: 'course-authoring.course-outline.configure-modal.basic-tab.release-date-and-time', defaultMessage: 'Release Date and Time', @@ -43,12 +47,16 @@ const messages = defineMessages({ }, sectionVisibilityWarning: { id: 'course-authoring.course-outline.configure-modal.visibility-tab.section-visibility-warning', - defaultMessage: 'If you make this section visible to learners, learners will be able to see its content after the release date has passed and you have published the unit. Only units that are explicitly hidden from learners will remain hidden after you clear this option for the section.', + defaultMessage: 'If you make this section visible to learners, learners will be able to see its content after the release date has passed and you have published the section. Only units that are explicitly hidden from learners will remain hidden after you clear this option for the section.', }, unitVisibilityWarning: { id: 'course-authoring.course-outline.configure-modal.unit-tab.unit-visibility-warning', defaultMessage: 'If the unit was previously published and released to learners, any changes you made to the unit when it was hidden will now be visible to learners.', }, + subsectionVisibilityWarning: { + id: 'course-authoring.course-outline.configure-modal.unit-tab.subsection-visibility-warning', + defaultMessage: 'If you select an option other than "Hide entire subsection", published units in this subsection will become available to learners unless they are explicitly hidden.', + }, unitSelectGroup: { id: 'course-authoring.course-outline.configure-modal.unit-tab.unit-select-group', defaultMessage: 'Select one or more groups:', @@ -157,6 +165,30 @@ const messages = defineMessages({ id: 'course-authoring.course-outline.configure-modal.advanced-tab.timed-description', defaultMessage: 'Use a timed exam to limit the time learners can spend on problems in this subsection. Learners must submit answers before the time expires. You can allow additional time for individual learners through the instructor Dashboard.', }, + proctoredExam: { + id: 'course-authoring.course-outline.configure-modal.advanced-tab.proctoredExam', + defaultMessage: 'Proctored', + }, + proctoredExamDescription: { + id: 'course-authoring.course-outline.configure-modal.advanced-tab.timed-description', + defaultMessage: 'Proctored exams are timed and they record video of each learner taking the exam. The videos are then reviewed to ensure that learners follow all examination rules. Please note that setting this exam as proctored will change the visibility settings to "Hide content after due date."', + }, + onboardingExam: { + id: 'course-authoring.course-outline.configure-modal.advanced-tab.onboardingExam', + defaultMessage: 'Onboarding', + }, + onboardingExamDescription: { + id: 'course-authoring.course-outline.configure-modal.advanced-tab.timed-description', + defaultMessage: 'Use Onboarding to introduce learners to proctoring, verify their identity, and create an onboarding profile. Learners must complete the onboarding profile step prior to taking a proctored exam. Profile reviews take 2+ business days.', + }, + practiceExam: { + id: 'course-authoring.course-outline.configure-modal.advanced-tab.practiceExam', + defaultMessage: 'Practice proctored', + }, + practiceExamDescription: { + id: 'course-authoring.course-outline.configure-modal.advanced-tab.timed-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.', + }, advancedTabTitle: { id: 'course-authoring.course-outline.configure-modal.advanced-tab.title', defaultMessage: 'Advanced', @@ -169,6 +201,70 @@ const messages = defineMessages({ id: 'course-authoring.course-outline.configure-modal.advanced-tab.time-limit-description', defaultMessage: 'Select a time allotment for the exam. If it is over 24 hours, type in the amount of time. You can grant individual learners extra time to complete the exam through the Instructor Dashboard.', }, + prereqTitle: { + id: 'course-authoring.course-outline.configure-modal.advanced-tab.prereqTitle', + defaultMessage: 'Use as a Prerequisite', + }, + prereqCheckboxLabel: { + id: 'course-authoring.course-outline.configure-modal.advanced-tab.prereqCheckboxLabel', + defaultMessage: 'Make this subsection available as a prerequisite to other content', + }, + limitAccessTitle: { + id: 'course-authoring.course-outline.configure-modal.advanced-tab.limitAccessTitle', + defaultMessage: 'Limit access', + }, + limitAccessDescription: { + id: 'course-authoring.course-outline.configure-modal.advanced-tab.limitAccessDescription', + defaultMessage: 'Select a prerequisite subsection and enter a minimum score percentage and minimum completion percentage to limit access to this subsection. Allowed values are 0-100', + }, + noPrerequisiteOption: { + id: 'course-authoring.course-outline.configure-modal.advanced-tab.noPrerequisiteOption', + defaultMessage: 'No prerequisite', + }, + prerequisiteSelectLabel: { + id: 'course-authoring.course-outline.configure-modal.advanced-tab.prerequisiteSelectLabel', + defaultMessage: 'Prerequisite:', + }, + minScoreLabel: { + id: 'course-authoring.course-outline.configure-modal.advanced-tab.minScoreLabel', + defaultMessage: 'Minimum score:', + }, + minCompletionLabel: { + id: 'course-authoring.course-outline.configure-modal.advanced-tab.minCompletionLabel', + defaultMessage: 'Minimum completion:', + }, + minScoreError: { + id: 'course-authoring.course-outline.configure-modal.advanced-tab.minScoreError', + defaultMessage: 'The minimum score percentage must be a whole number between 0 and 100.', + }, + minCompletionError: { + id: 'course-authoring.course-outline.configure-modal.advanced-tab.minCompletionError', + defaultMessage: 'The minimum completion percentage must be a whole number between 0 and 100.', + }, + proctoredExamLockedAndisNotProctoredExamAlert: { + id: 'course-authoring.course-outline.configure-modal.advanced-tab.proctoredExamLockedAndisNotProctoredExamAlert', + defaultMessage: 'This subsection was released to learners as a proctored exam, but was reverted back to a basic or timed exam. You may not configure it as a proctored exam now. Contact edX Support for assistance.', + }, + proctoredExamLockedAndisProctoredExamAlert: { + id: 'course-authoring.course-outline.configure-modal.advanced-tab.proctoredExamLockedAndisProctoredExamAlert', + defaultMessage: 'This proctored exam has been released to learners. You may not convert it to another type of special exam. You may revert this subsection back to being a basic exam by selecting \'None\', or a timed exam, but you will NOT be able to configure it as a proctored exam in the future.', + }, + reviewRulesLabel: { + id: 'course-authoring.course-outline.configure-modal.advanced-tab.reviewRulesLabel', + defaultMessage: 'Review rules', + }, + reviewRulesDescription: { + id: 'course-authoring.course-outline.configure-modal.advanced-tab.reviewRulesDescription', + defaultMessage: 'Specify any rules or rule exceptions that the proctoring review team should enforce when reviewing the videos. For example, you could specify that calculators are allowed. These specified rules are visible to learners before the learners start the exam.', + }, + reviewRulesDescriptionWithLink: { + id: 'course-authoring.course-outline.configure-modal.advanced-tab.reviewRulesDescriptionWithLink', + defaultMessage: 'Specify any rules or rule exceptions that the proctoring review team should enforce when reviewing the videos. For example, you could specify that calculators are allowed. These specified rules are visible to learners before the learners start the exam, along with the {hyperlink}.', + }, + reviewRulesDescriptionLinkText: { + id: 'course-authoring.course-outline.configure-modal.advanced-tab.reviewRulesDescriptionLinkText', + defaultMessage: 'general proctored exam rules', + }, }); export default messages; diff --git a/src/course-outline/data/api.js b/src/course-outline/data/api.js index d6de77a1c..3c2e03808 100644 --- a/src/course-outline/data/api.js +++ b/src/course-outline/data/api.js @@ -243,11 +243,19 @@ export async function configureCourseSection(sectionId, isVisibleToStaffOnly, st * @param {string} isVisibleToStaffOnly * @param {string} releaseDate * @param {string} graderType - * @param {string} dueDateState - * @param {string} isTimeLimitedState - * @param {string} defaultTimeLimitMin - * @param {string} hideAfterDueState - * @param {string} showCorrectnessState + * @param {string} dueDate + * @param {boolean} isProctoredExam, + * @param {boolean} isOnboardingExam, + * @param {boolean} isPracticeExam, + * @param {string} examReviewRules, + * @param {boolean} isTimeLimited + * @param {number} defaultTimeLimitMin + * @param {string} hideAfterDue + * @param {string} showCorrectness + * @param {boolean} isPrereq, + * @param {string} prereqUsageKey, + * @param {number} prereqMinScore, + * @param {number} prereqMinCompletion, * @returns {Promise} */ export async function configureCourseSubsection( @@ -255,28 +263,40 @@ export async function configureCourseSubsection( isVisibleToStaffOnly, releaseDate, graderType, - dueDateState, - isTimeLimitedState, + dueDate, + isTimeLimited, + isProctoredExam, + isOnboardingExam, + isPracticeExam, + examReviewRules, defaultTimeLimitMin, - hideAfterDueState, - showCorrectnessState, + hideAfterDue, + showCorrectness, + isPrereq, + prereqUsageKey, + prereqMinScore, + prereqMinCompletion, ) { const { data } = await getAuthenticatedHttpClient() .post(getCourseItemApiUrl(itemId), { publish: 'republish', graderType, + isPrereq, + prereqUsageKey, + prereqMinScore, + prereqMinCompletion, metadata: { // The backend expects metadata.visible_to_staff_only to either true or null visible_to_staff_only: isVisibleToStaffOnly ? true : null, - due: dueDateState, - hide_after_due: hideAfterDueState, - show_correctness: showCorrectnessState, - is_practice_exam: false, - is_time_limited: isTimeLimitedState, - exam_review_rules: '', - is_proctored_enabled: false, + due: dueDate, + hide_after_due: hideAfterDue, + show_correctness: showCorrectness, + is_practice_exam: isPracticeExam, + is_time_limited: isTimeLimited, + is_proctored_enabled: isProctoredExam || isPracticeExam || isOnboardingExam, + exam_review_rules: examReviewRules, default_time_limit_minutes: defaultTimeLimitMin, - is_onboarding_exam: false, + is_onboarding_exam: isOnboardingExam, start: releaseDate, }, }); @@ -442,3 +462,13 @@ export async function pasteBlock(parentLocator) { return data; } + +/** + * Dismiss notification + * @param {string} url + * @returns void +*/ +export async function dismissNotification(url) { + await getAuthenticatedHttpClient() + .delete(url); +} diff --git a/src/course-outline/data/selectors.js b/src/course-outline/data/selectors.js index 88d9e9a91..fcf1b4881 100644 --- a/src/course-outline/data/selectors.js +++ b/src/course-outline/data/selectors.js @@ -9,3 +9,4 @@ export const getCurrentSubsection = (state) => state.courseOutline.currentSubsec export const getCourseActions = (state) => state.courseOutline.actions; export const getCustomRelativeDatesActiveFlag = (state) => state.courseOutline.isCustomRelativeDatesActive; export const getInitialUserClipboard = (state) => state.courseOutline.initialUserClipboard; +export const getProctoredExamsFlag = (state) => state.courseOutline.enableProctoredExams; diff --git a/src/course-outline/data/slice.js b/src/course-outline/data/slice.js index 6c2da8988..0e8a3d342 100644 --- a/src/course-outline/data/slice.js +++ b/src/course-outline/data/slice.js @@ -44,6 +44,7 @@ const slice = createSlice({ sourceContexttitle: null, sourceEditUrl: null, }, + enableProctoredExams: false, }, reducers: { fetchOutlineIndexSuccess: (state, { payload }) => { @@ -51,6 +52,7 @@ const slice = createSlice({ state.sectionsList = payload.courseStructure?.childInfo?.children || []; state.isCustomRelativeDatesActive = payload.isCustomRelativeDatesActive; state.initialUserClipboard = payload.initialUserClipboard; + state.enableProctoredExams = payload.courseStructure?.enableProctoredExams; }, updateOutlineIndexLoadingStatus: (state, { payload }) => { state.loadingStatus = { diff --git a/src/course-outline/data/thunk.js b/src/course-outline/data/thunk.js index 611aaa2db..f5f4bd392 100644 --- a/src/course-outline/data/thunk.js +++ b/src/course-outline/data/thunk.js @@ -30,6 +30,7 @@ import { setCourseItemOrderList, copyBlockToClipboard, pasteBlock, + dismissNotification, } from './api'; import { addSection, @@ -261,11 +262,19 @@ export function configureCourseSubsectionQuery( isVisibleToStaffOnly, releaseDate, graderType, - dueDateState, - isTimeLimitedState, + dueDate, + isTimeLimited, + isProctoredExam, + isOnboardingExam, + isPracticeExam, + examReviewRules, defaultTimeLimitMin, - hideAfterDueState, - showCorrectnessState, + hideAfterDue, + showCorrectness, + isPrereq, + prereqUsageKey, + prereqMinScore, + prereqMinCompletion, ) { return async (dispatch) => { dispatch(configureCourseItemQuery( @@ -275,11 +284,19 @@ export function configureCourseSubsectionQuery( isVisibleToStaffOnly, releaseDate, graderType, - dueDateState, - isTimeLimitedState, + dueDate, + isTimeLimited, + isProctoredExam, + isOnboardingExam, + isPracticeExam, + examReviewRules, defaultTimeLimitMin, - hideAfterDueState, - showCorrectnessState, + hideAfterDue, + showCorrectness, + isPrereq, + prereqUsageKey, + prereqMinScore, + prereqMinCompletion, ), )); }; @@ -607,3 +624,17 @@ export function pasteClipboardContent(parentLocator, sectionId) { } }; } + +export function dismissNotificationQuery(url) { + return async (dispatch) => { + dispatch(updateSavingStatus({ status: RequestStatus.PENDING })); + + try { + await dismissNotification(url).then(async () => { + dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL })); + }); + } catch (error) { + dispatch(updateSavingStatus({ status: RequestStatus.FAILED })); + } + }; +} diff --git a/src/course-outline/hooks.jsx b/src/course-outline/hooks.jsx index 40528f83d..ead67142a 100644 --- a/src/course-outline/hooks.jsx +++ b/src/course-outline/hooks.jsx @@ -52,13 +52,26 @@ import { setUnitOrderListQuery, setClipboardContent, pasteClipboardContent, + dismissNotificationQuery, } from './data/thunk'; const useCourseOutline = ({ courseId }) => { const dispatch = useDispatch(); const navigate = useNavigate(); - const { reindexLink, courseStructure, lmsLink } = useSelector(getOutlineIndexData); + const { + reindexLink, + courseStructure, + lmsLink, + notificationDismissUrl, + discussionsSettings, + discussionsIncontextFeedbackUrl, + discussionsIncontextLearnmoreUrl, + deprecatedBlocksInfo, + proctoringErrors, + mfeProctoredExamSettingsUrl, + advanceSettingsUrl, + } = useSelector(getOutlineIndexData); const { outlineIndexLoadingStatus, reIndexLoadingStatus } = useSelector(getLoadingStatus); const statusBarData = useSelector(getStatusBarData); const savingStatus = useSelector(getSavingStatus); @@ -100,7 +113,7 @@ const useCourseOutline = ({ courseId }) => { const getUnitUrl = (locator) => { if (getConfig().ENABLE_UNIT_PAGE === 'true') { - return `/course/container/${locator}`; + return `/course/${courseId}/container/${locator}`; } return `${getConfig().STUDIO_BASE_URL}/container/${locator}`; }; @@ -239,6 +252,10 @@ const useCourseOutline = ({ courseId }) => { dispatch(setUnitOrderListQuery(sectionId, subsectionId, unitListIds, restoreCallback)); }; + const handleDismissNotification = () => { + dispatch(dismissNotificationQuery(notificationDismissUrl)); + }; + useEffect(() => { dispatch(fetchCourseOutlineIndexQuery(courseId)); dispatch(fetchCourseBestPracticesQuery({ courseId })); @@ -306,6 +323,15 @@ const useCourseOutline = ({ courseId }) => { handleUnitDragAndDrop, handleCopyToClipboardClick, handlePasteClipboardClick, + notificationDismissUrl, + discussionsSettings, + discussionsIncontextFeedbackUrl, + discussionsIncontextLearnmoreUrl, + deprecatedBlocksInfo, + proctoringErrors, + mfeProctoredExamSettingsUrl, + handleDismissNotification, + advanceSettingsUrl, }; }; diff --git a/src/course-outline/messages.js b/src/course-outline/messages.js index b1ff8cde6..b0851f423 100644 --- a/src/course-outline/messages.js +++ b/src/course-outline/messages.js @@ -33,10 +33,6 @@ const messages = defineMessages({ id: 'course-authoring.course-outline.section-list.button.new-section', defaultMessage: 'New section', }, - alertFailedGeneric: { - id: 'course-authoring.course-outline.general.alert.error.description', - defaultMessage: 'Unable to {actionName} {type}. Please try again.', - }, }); export default messages; diff --git a/src/course-outline/page-alerts/PageAlerts.jsx b/src/course-outline/page-alerts/PageAlerts.jsx new file mode 100644 index 000000000..bc882b101 --- /dev/null +++ b/src/course-outline/page-alerts/PageAlerts.jsx @@ -0,0 +1,283 @@ +import React, { useState } from 'react'; +import PropTypes from 'prop-types'; +import { getConfig } from '@edx/frontend-platform'; +import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n'; +import { ErrorAlert } from '@edx/frontend-lib-content-components'; +import { + Campaign as CampaignIcon, + InfoOutline as InfoOutlineIcon, + Warning as WarningIcon, +} from '@edx/paragon/icons'; +import { Alert, Button, Hyperlink } from '@edx/paragon'; + +import { RequestStatus } from '../../data/constants'; +import AlertMessage from '../../generic/alert-message'; +import AlertProctoringError from '../../generic/AlertProctoringError'; +import messages from './messages'; +import advancedSettingsMessages from '../../advanced-settings/messages'; + +const PageAlerts = ({ + notificationDismissUrl, + handleDismissNotification, + discussionsSettings, + discussionsIncontextFeedbackUrl, + discussionsIncontextLearnmoreUrl, + deprecatedBlocksInfo, + proctoringErrors, + mfeProctoredExamSettingsUrl, + advanceSettingsUrl, + savingStatus, +}) => { + const intl = useIntl(); + const studioBaseUrl = getConfig().STUDIO_BASE_URL; + const [showConfigAlert, setShowConfigAlert] = useState(true); + const [showDiscussionAlert, setShowDiscussionAlert] = useState(true); + + const configurationErrors = () => { + if (!notificationDismissUrl) { + return null; + } + + const onDismiss = () => { + setShowConfigAlert(false); + handleDismissNotification(); + }; + + return ( + + ); + }; + + const discussionNotification = () => { + const { providerType } = discussionsSettings || {}; + if (providerType !== 'openedx') { + return null; + } + + const onDismiss = () => { + setShowDiscussionAlert(false); + }; + + return ( + + {intl.formatMessage(messages.discussionNotificationLearnMore)} + , + ]} + > +
+ {intl.formatMessage(messages.discussionNotificationText, { + platformName: process.env.SITE_NAME, + })} +
+ + {intl.formatMessage(messages.discussionNotificationFeedback)} + +
+ ); + }; + + const deprecationWarning = () => { + const { + blocks, + deprecatedEnabledBlockTypes, + } = deprecatedBlocksInfo || {}; + + if (blocks?.length > 0 || deprecatedEnabledBlockTypes?.length > 0) { + return ( + + + {intl.formatMessage(messages.deprecationWarningTitle)} + + {blocks?.length > 0 && ( + <> +
+ {intl.formatMessage(messages.deprecationWarningBlocksText)} +
+
    + {blocks.map(([parentUrl, name]) => ( +
  • + + {name || intl.formatMessage(messages.deprecatedComponentName)} + +
  • + ))} +
+ + )} + {deprecatedEnabledBlockTypes?.length > 0 && ( + <> +
+ + + + ), + }} + /> +
+
    + {deprecatedEnabledBlockTypes.map((name) => ( +
  • + {name} +
  • + ))} +
+ + )} +
+ ); + } + + return null; + }; + + const proctoringAlerts = () => { + if (proctoringErrors?.length > 0) { + return ( + + ); + } + return null; + }; + + return ( + <> + {configurationErrors()} + {discussionNotification()} + {deprecationWarning()} + {proctoringAlerts()} + + {intl.formatMessage(messages.alertFailedGeneric, { actionName: 'save', type: 'changes' })} + + + ); +}; + +PageAlerts.defaultProps = { + notificationDismissUrl: '', + handleDismissNotification: null, + discussionsSettings: {}, + discussionsIncontextFeedbackUrl: '', + discussionsIncontextLearnmoreUrl: '', + deprecatedBlocksInfo: {}, + proctoringErrors: [], + mfeProctoredExamSettingsUrl: '', + advanceSettingsUrl: '', + savingStatus: '', +}; + +PageAlerts.propTypes = { + notificationDismissUrl: PropTypes.string, + handleDismissNotification: PropTypes.func, + discussionsSettings: PropTypes.shape({ + providerType: PropTypes.string, + }), + discussionsIncontextFeedbackUrl: PropTypes.string, + discussionsIncontextLearnmoreUrl: PropTypes.string, + deprecatedBlocksInfo: PropTypes.shape({ + blocks: PropTypes.arrayOf(PropTypes.arrayOf(PropTypes.string)), + deprecatedEnabledBlockTypes: PropTypes.arrayOf(PropTypes.string), + advanceSettingsUrl: PropTypes.string, + }), + proctoringErrors: PropTypes.arrayOf(PropTypes.shape({ + key: PropTypes.string, + message: PropTypes.string, + model: PropTypes.shape({ + deprecated: PropTypes.bool, + displayName: PropTypes.string, + help: PropTypes.string, + hideOnEnabledPublisher: PropTypes.bool, + }), + value: PropTypes.string, + })), + mfeProctoredExamSettingsUrl: PropTypes.string, + advanceSettingsUrl: PropTypes.string, + savingStatus: PropTypes.string, +}; + +export default PageAlerts; diff --git a/src/course-outline/page-alerts/PageAlerts.test.jsx b/src/course-outline/page-alerts/PageAlerts.test.jsx new file mode 100644 index 000000000..c40cafd08 --- /dev/null +++ b/src/course-outline/page-alerts/PageAlerts.test.jsx @@ -0,0 +1,155 @@ +import React from 'react'; +import { act, render, fireEvent } from '@testing-library/react'; +import { IntlProvider } from '@edx/frontend-platform/i18n'; +import { AppProvider } from '@edx/frontend-platform/react'; +import { initializeMockApp, getConfig } from '@edx/frontend-platform'; + +import PageAlerts from './PageAlerts'; +import messages from './messages'; +import initializeStore from '../../store'; + +jest.mock('@edx/frontend-platform/i18n', () => ({ + ...jest.requireActual('@edx/frontend-platform/i18n'), + useIntl: () => ({ + formatMessage: (message) => message.defaultMessage, + }), +})); + +let store; +const handleDismissNotification = jest.fn(); + +const pageAlertsData = { + notificationDismissUrl: '', + handleDismissNotification: null, + discussionsSettings: {}, + discussionsIncontextFeedbackUrl: '', + discussionsIncontextLearnmoreUrl: '', + deprecatedBlocksInfo: {}, + proctoringErrors: [], + mfeProctoredExamSettingsUrl: '', + advanceSettingsUrl: '', + savingStatus: '', +}; + +const renderComponent = (props) => render( + + + + + , +); + +describe('', () => { + beforeEach(() => { + initializeMockApp({ + authenticatedUser: { + userId: 3, + username: 'abc123', + administrator: true, + roles: [], + }, + }); + store = initializeStore(); + }); + + it('renders null when no alerts are present', () => { + const { container } = renderComponent(); + expect(container).toBeEmptyDOMElement(); + }); + + it('renders configuration alerts', async () => { + const { queryByText } = renderComponent({ + ...pageAlertsData, + notificationDismissUrl: 'some-url', + handleDismissNotification, + }); + + expect(queryByText(messages.configurationErrorTitle.defaultMessage)).toBeInTheDocument(); + const dismissBtn = queryByText('Dismiss'); + await act(async () => fireEvent.click(dismissBtn)); + + expect(handleDismissNotification).toBeCalled(); + }); + + it('renders discussion alerts', async () => { + const { queryByText } = renderComponent({ + ...pageAlertsData, + discussionsSettings: { + providerType: 'openedx', + }, + discussionsIncontextFeedbackUrl: 'some-feedback-url', + discussionsIncontextLearnmoreUrl: 'some-learn-more-url', + }); + + expect(queryByText(messages.discussionNotificationText.defaultMessage)).toBeInTheDocument(); + const learnMoreBtn = queryByText(messages.discussionNotificationLearnMore.defaultMessage); + expect(learnMoreBtn).toBeInTheDocument(); + expect(learnMoreBtn).toHaveAttribute('href', 'some-learn-more-url'); + + const feedbackLink = queryByText(messages.discussionNotificationFeedback.defaultMessage); + expect(feedbackLink).toBeInTheDocument(); + expect(feedbackLink).toHaveAttribute('href', 'some-feedback-url'); + }); + + it('renders deprecation warning alerts', async () => { + const { queryByText } = renderComponent({ + ...pageAlertsData, + deprecatedBlocksInfo: { + blocks: [['url1', 'block1'], ['url2']], + deprecatedEnabledBlockTypes: ['lti', 'video'], + advanceSettingsUrl: '/some-url', + }, + }); + + expect(queryByText(messages.deprecationWarningTitle.defaultMessage)).toBeInTheDocument(); + expect(queryByText(messages.deprecationWarningBlocksText.defaultMessage)).toBeInTheDocument(); + expect(queryByText('block1')).toHaveAttribute('href', 'url1'); + expect(queryByText(messages.deprecatedComponentName.defaultMessage)).toHaveAttribute('href', 'url2'); + + const feedbackLink = queryByText(messages.advancedSettingLinkText.defaultMessage); + expect(feedbackLink).toBeInTheDocument(); + expect(feedbackLink).toHaveAttribute('href', `${getConfig().STUDIO_BASE_URL}/some-url`); + expect(queryByText('lti')).toBeInTheDocument(); + expect(queryByText('video')).toBeInTheDocument(); + }); + + it('renders proctoring alerts with mfe settings link', async () => { + const { queryByText } = renderComponent({ + ...pageAlertsData, + mfeProctoredExamSettingsUrl: 'mfe-url', + proctoringErrors: [ + { key: '1', model: { displayName: 'error 1' }, message: 'message 1' }, + { key: '2', model: { displayName: 'error 2' }, message: 'message 2' }, + ], + }); + + expect(queryByText('error 1')).toBeInTheDocument(); + expect(queryByText('error 2')).toBeInTheDocument(); + expect(queryByText('message 1')).toBeInTheDocument(); + expect(queryByText('message 2')).toBeInTheDocument(); + expect(queryByText(messages.proctoredSettingsLinkText.defaultMessage)).toHaveAttribute('href', 'mfe-url'); + }); + + it('renders proctoring alerts without mfe settings link', async () => { + const { queryByText } = renderComponent({ + ...pageAlertsData, + advanceSettingsUrl: '/some-url', + proctoringErrors: [ + { key: '1', model: { displayName: 'error 1' }, message: 'message 1' }, + { key: '2', model: { displayName: 'error 2' }, message: 'message 2' }, + ], + }); + + expect(queryByText('error 1')).toBeInTheDocument(); + expect(queryByText('error 2')).toBeInTheDocument(); + expect(queryByText('message 1')).toBeInTheDocument(); + expect(queryByText('message 2')).toBeInTheDocument(); + expect(queryByText(messages.advancedSettingLinkText.defaultMessage)).toHaveAttribute( + 'href', + `${getConfig().STUDIO_BASE_URL}/some-url`, + ); + }); +}); diff --git a/src/course-outline/page-alerts/messages.js b/src/course-outline/page-alerts/messages.js new file mode 100644 index 000000000..5964dbdf1 --- /dev/null +++ b/src/course-outline/page-alerts/messages.js @@ -0,0 +1,62 @@ +import { defineMessages } from '@edx/frontend-platform/i18n'; + +const messages = defineMessages({ + configurationErrorTitle: { + id: 'course-authoring.course-outline.page-alerts.configurationErrorTitle', + defaultMessage: 'This course was created as a re-run. Some manual configuration is needed.', + }, + configurationErrorText: { + id: 'course-authoring.course-outline.page-alerts.configurationErrorText', + defaultMessage: 'No course content is currently visible, and no learners are enrolled. Be sure to review and reset all dates, including the Course Start Date; set up the course team; review course updates and other assets for dated material; and seed the discussions and wiki.', + }, + discussionNotificationText: { + id: 'course-authoring.course-outline.page-alerts.discussionNotificationText', + defaultMessage: 'This course run is using an upgraded version of {platformName} discussion forum. In order to display the discussions sidebar, discussions xBlocks will no longer be visible to learners.', + }, + discussionNotificationLearnMore: { + id: 'course-authoring.course-outline.page-alerts.discussionNotificationLearnMore', + defaultMessage: 'Learn more', + }, + discussionNotificationFeedback: { + id: 'course-authoring.course-outline.page-alerts.discussionNotificationLearnMore', + defaultMessage: 'Share feedback', + }, + deprecationWarningTitle: { + id: 'course-authoring.course-outline.page-alerts.deprecationWarningTitle', + defaultMessage: 'This course uses features that are no longer supported.', + }, + deprecationWarningBlocksText: { + id: 'course-authoring.course-outline.page-alerts.deprecationWarningBlocksText', + defaultMessage: 'You must delete or replace the following components.', + }, + deprecationWarningDeprecatedBlockText: { + id: 'course-authoring.course-outline.page-alerts.deprecationWarningDeprecatedBlockText', + defaultMessage: 'To avoid errors, {platformName} strongly recommends that you remove unsupported features from the course advanced settings. To do this, go to the {hyperlink}, locate the "Advanced Module List" setting, and then delete the following modules from the list.', + }, + advancedSettingLinkText: { + id: 'course-authoring.course-outline.page-alerts.advancedSettingLinkText', + defaultMessage: 'Advanced Settings page', + }, + deprecatedComponentName: { + id: 'course-authoring.course-outline.page-alerts.deprecatedComponentName', + defaultMessage: 'Deprecated Component', + }, + proctoringErrorTitle: { + id: 'course-authoring.course-outline.page-alerts.proctoringErrorTitle', + defaultMessage: 'This course has proctored exam settings that are incomplete or invalid.', + }, + proctoringErrorText: { + id: 'course-authoring.course-outline.page-alerts.proctoringErrorText', + defaultMessage: 'To update these settings go to the {hyperlink}.', + }, + proctoredSettingsLinkText: { + id: 'course-authoring.course-outline.page-alerts.proctoredSettingsLinkText', + defaultMessage: 'Proctored Exam Settings page', + }, + alertFailedGeneric: { + id: 'course-authoring.course-outline.page-alert.generic-error.description', + defaultMessage: 'Unable to {actionName} {type}. Please try again.', + }, +}); + +export default messages; diff --git a/src/course-outline/section-card/SectionCard.jsx b/src/course-outline/section-card/SectionCard.jsx index 07fe2c1cc..8d2f2b173 100644 --- a/src/course-outline/section-card/SectionCard.jsx +++ b/src/course-outline/section-card/SectionCard.jsx @@ -4,14 +4,13 @@ import React, { import PropTypes from 'prop-types'; import { useDispatch } from 'react-redux'; import { useIntl } from '@edx/frontend-platform/i18n'; -import { Badge, Button, useToggle } from '@edx/paragon'; +import { Bubble, Button, useToggle } from '@edx/paragon'; import { Add as IconAdd } from '@edx/paragon/icons'; import classNames from 'classnames'; import { setCurrentItem, setCurrentSection } from '../data/slice'; import { RequestStatus } from '../../data/constants'; import CardHeader from '../card-header/CardHeader'; -import BaseTitleWithStatusBadge from '../card-header/BaseTitleWithStatusBadge'; import ConditionalSortableElement from '../drag-helper/ConditionalSortableElement'; import TitleButton from '../card-header/TitleButton'; import XBlockStatus from '../xblock-status/XBlockStatus'; @@ -123,16 +122,11 @@ const SectionCard = ({ const titleComponent = ( - - + /> ); const isDraggable = actions.draggable && (actions.allowMoveUp || actions.allowMoveDown); @@ -183,9 +177,9 @@ const SectionCard = ({ variant="tertiary" onClick={handleOpenHighlightsModal} > - + {highlights.length} - +

{messages.sectionHighlightsBadge.defaultMessage}

diff --git a/src/course-outline/section-card/SectionCard.scss b/src/course-outline/section-card/SectionCard.scss index 25e3c688c..6eae48677 100644 --- a/src/course-outline/section-card/SectionCard.scss +++ b/src/course-outline/section-card/SectionCard.scss @@ -13,14 +13,6 @@ color: $headings-color; } - - .highlights-badge { - width: 1.5rem; - height: 1.5rem; - border-radius: 1.375rem; - font-size: 1rem; - } - .section-card__content { margin-left: 1.7rem; } diff --git a/src/course-outline/status-bar/StatusBar.jsx b/src/course-outline/status-bar/StatusBar.jsx index 4779b3a70..3ff28209f 100644 --- a/src/course-outline/status-bar/StatusBar.jsx +++ b/src/course-outline/status-bar/StatusBar.jsx @@ -1,8 +1,9 @@ import React, { useContext } from 'react'; +import moment from 'moment/moment'; import PropTypes from 'prop-types'; -import { useIntl } from '@edx/frontend-platform/i18n'; +import { FormattedDate, useIntl } from '@edx/frontend-platform/i18n'; import { - Button, Hyperlink, SelectMenu, MenuItem, Stack, + Button, Hyperlink, Form, Stack, } from '@edx/paragon'; import { AppContext } from '@edx/frontend-platform/react'; @@ -37,6 +38,7 @@ const StatusBar = ({ totalCourseBestPracticesChecks, } = checklist; + const courseReleaseDateObj = moment.utc(courseReleaseDate, 'MMM DD, YYYY at HH:mm UTC', true); const checkListTitle = `${completedCourseLaunchChecks + completedCourseBestPracticesChecks}/${totalCourseLaunchChecks + totalCourseBestPracticesChecks}`; const checklistDestination = () => new URL(`checklists/${courseId}`, config.STUDIO_BASE_URL).href; const scheduleDestination = () => new URL(`settings/details/${courseId}#schedule`, config.STUDIO_BASE_URL).href; @@ -52,18 +54,27 @@ const StatusBar = ({ } return ( - -
+ +
{intl.formatMessage(messages.startDateTitle)}
- {courseReleaseDate} + {courseReleaseDateObj.isValid() ? ( + + ) : courseReleaseDate}
-
+
{intl.formatMessage(messages.pacingTypeTitle)}
{isSelfPaced @@ -71,7 +82,7 @@ const StatusBar = ({ : intl.formatMessage(messages.pacingTypeInstructorPaced)}
-
+
{intl.formatMessage(messages.checklistTitle)}
-
+
{intl.formatMessage(messages.highlightEmailsTitle)}
-
+
{highlightsEnabledForMessaging ? ( {intl.formatMessage(messages.highlightEmailsEnabled)} @@ -104,26 +115,31 @@ const StatusBar = ({
{videoSharingEnabled && ( -
-
{intl.formatMessage(messages.videoSharingTitle)}
-
- + {intl.formatMessage(messages.videoSharingTitle)} + +
+ handleVideoSharingOptionChange(e.target.value)} + > {Object.values(VIDEO_SHARING_OPTIONS).map((option) => ( - handleVideoSharingOptionChange(option)} > {getVideoSharingOptionText(option, messages, intl)} - + ))} - +
-
+ + )} ); diff --git a/src/course-outline/status-bar/StatusBar.test.jsx b/src/course-outline/status-bar/StatusBar.test.jsx index 9b17be01b..5891720be 100644 --- a/src/course-outline/status-bar/StatusBar.test.jsx +++ b/src/course-outline/status-bar/StatusBar.test.jsx @@ -74,10 +74,10 @@ describe('', () => { }); it('renders StatusBar component correctly', () => { - const { queryByTestId, getByText } = renderComponent(); + const { getByText } = renderComponent(); expect(getByText(messages.startDateTitle.defaultMessage)).toBeInTheDocument(); - expect(getByText(statusBarData.courseReleaseDate)).toBeInTheDocument(); + expect(getByText('Feb 05, 2013, 5:00 AM')).toBeInTheDocument(); expect(getByText(messages.pacingTypeTitle.defaultMessage)).toBeInTheDocument(); expect(getByText(messages.pacingTypeSelfPaced.defaultMessage)).toBeInTheDocument(); @@ -88,7 +88,7 @@ describe('', () => { expect(getByText(messages.highlightEmailsTitle.defaultMessage)).toBeInTheDocument(); expect(getByText(messages.highlightEmailsEnabled.defaultMessage)).toBeInTheDocument(); - expect(queryByTestId('video-sharing-wrapper')).toBeInTheDocument(); + expect(getByText(messages.videoSharingTitle.defaultMessage)).toBeInTheDocument(); }); it('renders StatusBar when isSelfPaced is false', () => { diff --git a/src/course-outline/subsection-card/SubsectionCard.jsx b/src/course-outline/subsection-card/SubsectionCard.jsx index 8de54ec27..9a40e130f 100644 --- a/src/course-outline/subsection-card/SubsectionCard.jsx +++ b/src/course-outline/subsection-card/SubsectionCard.jsx @@ -11,7 +11,6 @@ import { setCurrentItem, setCurrentSection, setCurrentSubsection } from '../data import { RequestStatus } from '../../data/constants'; import { COURSE_BLOCK_NAMES } from '../constants'; import CardHeader from '../card-header/CardHeader'; -import BaseTitleWithStatusBadge from '../card-header/BaseTitleWithStatusBadge'; import ConditionalSortableElement from '../drag-helper/ConditionalSortableElement'; import TitleButton from '../card-header/TitleButton'; import XBlockStatus from '../xblock-status/XBlockStatus'; @@ -55,6 +54,7 @@ const SubsectionCard = ({ actions: subsectionActions, isHeaderVisible = true, enableCopyPasteUnits = false, + proctoringExamConfigurationLink, } = subsection; // re-create actions object for customizations @@ -103,16 +103,11 @@ const SubsectionCard = ({ const titleComponent = ( - - + /> ); useEffect(() => { @@ -168,6 +163,8 @@ const SubsectionCard = ({ titleComponent={titleComponent} namePrefix={namePrefix} actions={actions} + proctoringExamConfigurationLink={proctoringExamConfigurationLink} + isSequential />
{ const currentRef = useRef(null); const dispatch = useDispatch(); @@ -44,6 +44,7 @@ const UnitCard = ({ actions: unitActions, isHeaderVisible = true, enableCopyPasteUnits = false, + discussionEnabled, } = unit; // re-create actions object for customizations @@ -52,6 +53,11 @@ const UnitCard = ({ actions.allowMoveUp = canMoveItem(index, -1); actions.allowMoveDown = canMoveItem(index, 1); + const parentInfo = { + graded: subsection.graded, + isTimeLimited: subsection.isTimeLimited, + }; + const unitStatus = getItemStatus({ published, visibilityState, @@ -88,15 +94,10 @@ const UnitCard = ({ const titleComponent = ( - - + /> ); useEffect(() => { @@ -157,6 +158,9 @@ const UnitCard = ({ isVertical enableCopyPasteUnits={enableCopyPasteUnits} onClickCopy={handleCopyClick} + discussionEnabled={discussionEnabled} + discussionsSettings={discussionsSettings} + parentInfo={parentInfo} />
( +const AlertProctoringError = ({ proctoringErrorsData, children, ...props }) => (
    + {children} {proctoringErrorsData.map(({ key, model, message }) => (
  • {model.displayName} @@ -17,6 +18,7 @@ const AlertProctoringError = ({ proctoringErrorsData, ...props }) => ( AlertProctoringError.propTypes = { variant: PropTypes.string, + children: PropTypes.node, proctoringErrorsData: PropTypes.arrayOf(PropTypes.shape({ key: PropTypes.string, message: PropTypes.string, @@ -32,6 +34,7 @@ AlertProctoringError.propTypes = { AlertProctoringError.defaultProps = { variant: 'danger', + children: null, }; export default AlertProctoringError; diff --git a/src/generic/FormikControl.jsx b/src/generic/FormikControl.jsx index 981e3be02..f75c4833a 100644 --- a/src/generic/FormikControl.jsx +++ b/src/generic/FormikControl.jsx @@ -10,6 +10,7 @@ const FormikControl = ({ label, help, className, + controlClasses, ...params }) => { const { @@ -25,7 +26,7 @@ const FormikControl = ({ , label: <>, className: '', + controlClasses: 'pb-2', }; export default FormikControl;