diff --git a/src/course-outline/CourseOutline.jsx b/src/course-outline/CourseOutline.jsx index c9ec8acf89..d0d5b60d47 100644 --- a/src/course-outline/CourseOutline.jsx +++ b/src/course-outline/CourseOutline.jsx @@ -29,6 +29,7 @@ import SectionCard from './section-card/SectionCard'; import HighlightsModal from './highlights-modal/HighlightsModal'; 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 { useCourseOutline } from './hooks'; import messages from './messages'; @@ -51,11 +52,14 @@ const CourseOutline = ({ courseId }) => { isDisabledReindexButton, isHighlightsModalOpen, isPublishModalOpen, + isConfigureModalOpen, isDeleteModalOpen, closeHighlightsModal, closePublishModal, + closeConfigureModal, closeDeleteModal, openPublishModal, + openConfigureModal, openDeleteModal, headerNavigationsActions, openEnableHighlightsModal, @@ -65,6 +69,7 @@ const CourseOutline = ({ courseId }) => { handleOpenHighlightsModal, handleHighlightsFormSubmit, handlePublishSectionSubmit, + handleConfigureSectionSubmit, handleEditSectionSubmit, handleDeleteSectionSubmit, handleDuplicateSectionSubmit, @@ -143,6 +148,7 @@ const CourseOutline = ({ courseId }) => { savingStatus={savingStatus} onOpenHighlightsModal={handleOpenHighlightsModal} onOpenPublishModal={openPublishModal} + onOpenConfigureModal={openConfigureModal} onOpenDeleteModal={openDeleteModal} onEditSectionSubmit={handleEditSectionSubmit} onDuplicateSubmit={handleDuplicateSectionSubmit} @@ -188,6 +194,11 @@ const CourseOutline = ({ courseId }) => { onClose={closePublishModal} onPublishSubmit={handlePublishSectionSubmit} /> + ', () => { expect(firstSection.querySelector('.section-card-header__badge-status')).toHaveTextContent('Published not live'); }); + it('check configure section when configure query is successful', async () => { + cleanup(); + const { getAllByTestId, getByText, getByPlaceholderText } = render(); + const section = courseOutlineIndexMock.courseStructure.childInfo.children[0]; + const newReleaseDate = '2025-08-10T10:00:00Z'; + axiosMock + .onPost(getUpdateCourseSectionApiUrl(section.id), { + id: section.id, + data: null, + metadata: { + display_name: section.displayName, + start: newReleaseDate, + visible_to_staff_only: true, + }, + }) + .reply(200); + + axiosMock + .onGet(getXBlockApiUrl(section.id)) + .reply(200, { + ...section, + start: newReleaseDate, + }); + + await executeThunk(fetchCourseSectionQuery(section.id), store.dispatch); + + const firstSection = getAllByTestId('section-card')[0]; + + const sectionDropdownButton = firstSection.querySelector('#section-card-header__menu'); + expect(sectionDropdownButton).toBeInTheDocument(); + fireEvent.click(sectionDropdownButton); + + const configureBtn = getByText(cardHeaderMessages.menuConfigure.defaultMessage); + fireEvent.click(configureBtn); + + const datePicker = getByPlaceholderText('MM/DD/YYYY'); + + fireEvent.change(datePicker, { target: { value: '08/10/2025' } }); + fireEvent.click(getByText('Save')); + fireEvent.click(sectionDropdownButton); + fireEvent.click(configureBtn); + + expect(datePicker).toHaveValue('08/10/2025'); + }); + it('check update highlights when update highlights query is successfully', async () => { const { getByRole } = render(); diff --git a/src/course-outline/card-header/CardHeader.jsx b/src/course-outline/card-header/CardHeader.jsx index a6d87171a9..eeedf676b0 100644 --- a/src/course-outline/card-header/CardHeader.jsx +++ b/src/course-outline/card-header/CardHeader.jsx @@ -30,6 +30,7 @@ const CardHeader = ({ hasChanges, isExpanded, onClickPublish, + onClickConfigure, onClickMenuButton, onClickEdit, onExpand, @@ -136,7 +137,7 @@ const CardHeader = ({ > {intl.formatMessage(messages.menuPublish)} - {intl.formatMessage(messages.menuConfigure)} + {intl.formatMessage(messages.menuConfigure)} {intl.formatMessage(messages.menuDuplicate)} {intl.formatMessage(messages.menuDelete)} @@ -153,6 +154,7 @@ CardHeader.propTypes = { isExpanded: PropTypes.bool.isRequired, onExpand: PropTypes.func.isRequired, onClickPublish: PropTypes.func.isRequired, + onClickConfigure: PropTypes.func.isRequired, onClickMenuButton: PropTypes.func.isRequired, onClickEdit: PropTypes.func.isRequired, isFormOpen: PropTypes.bool.isRequired, diff --git a/src/course-outline/configure-modal/BasicTab.jsx b/src/course-outline/configure-modal/BasicTab.jsx new file mode 100644 index 0000000000..ffdfc81c5a --- /dev/null +++ b/src/course-outline/configure-modal/BasicTab.jsx @@ -0,0 +1,43 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { Stack } from '@edx/paragon'; +import { FormattedMessage, injectIntl, useIntl } from '@edx/frontend-platform/i18n'; +import messages from './messages'; +import { DatepickerControl, DATEPICKER_TYPES } from '../../generic/datepicker-control'; + +const BasicTab = ({ releaseDate, setReleaseDate }) => { + const intl = useIntl(); + const onChange = (value) => { + setReleaseDate(value); + }; + + return ( + <> +

+
+ + onChange(date)} + /> + onChange(date)} + /> + + + ); +}; + +BasicTab.propTypes = { + releaseDate: PropTypes.string.isRequired, + setReleaseDate: 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 new file mode 100644 index 0000000000..cedcf01989 --- /dev/null +++ b/src/course-outline/configure-modal/ConfigureModal.jsx @@ -0,0 +1,95 @@ +/* eslint-disable import/named */ +import React, { useEffect, useState } from 'react'; +import PropTypes from 'prop-types'; +import { useIntl } from '@edx/frontend-platform/i18n'; +import { + ModalDialog, + Button, + ActionRow, + Tab, + Tabs, +} from '@edx/paragon'; +import { useSelector } from 'react-redux'; + +import { VisibilityTypes } from '../../data/constants'; +import { getCurrentSection } from '../data/selectors'; +import messages from './messages'; +import BasicTab from './BasicTab'; +import VisibilityTab from './VisibilityTab'; + +const ConfigureModal = ({ + isOpen, + onClose, + onConfigureSubmit, +}) => { + const intl = useIntl(); + const { displayName, start: sectionStartDate, visibilityState } = useSelector(getCurrentSection); + const [releaseDate, setReleaseDate] = useState(sectionStartDate); + const [isVisibleToStaffOnly, setIsVisibleToStaffOnly] = useState(visibilityState === VisibilityTypes.STAFF_ONLY); + const [saveButtonDisabled, setSaveButtonDisabled] = useState(true); + + useEffect(() => { + setReleaseDate(sectionStartDate); + }, [sectionStartDate]); + + useEffect(() => { + setIsVisibleToStaffOnly(visibilityState === VisibilityTypes.STAFF_ONLY); + }, [visibilityState]); + + useEffect(() => { + const visibilityUnchanged = isVisibleToStaffOnly === (visibilityState === VisibilityTypes.STAFF_ONLY); + setSaveButtonDisabled(visibilityUnchanged && releaseDate === sectionStartDate); + }, [releaseDate, isVisibleToStaffOnly]); + + const handleSave = () => { + onConfigureSubmit(isVisibleToStaffOnly, releaseDate); + }; + + return ( + + + + {intl.formatMessage(messages.title, { title: displayName })} + + + + + + + + + + + + + + + + {intl.formatMessage(messages.cancelButton)} + + + + + + ); +}; + +ConfigureModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + onClose: PropTypes.func.isRequired, + onConfigureSubmit: PropTypes.func.isRequired, +}; + +export default ConfigureModal; diff --git a/src/course-outline/configure-modal/ConfigureModal.scss b/src/course-outline/configure-modal/ConfigureModal.scss new file mode 100644 index 0000000000..1fad13926a --- /dev/null +++ b/src/course-outline/configure-modal/ConfigureModal.scss @@ -0,0 +1,12 @@ +.configure-modal { + max-width: 33.6875rem; + overflow: visible; + + .configure-modal__header { + padding-top: 1.5rem; + } + + .configure-modal__body { + overflow: visible; + } +} diff --git a/src/course-outline/configure-modal/ConfigureModal.test.jsx b/src/course-outline/configure-modal/ConfigureModal.test.jsx new file mode 100644 index 0000000000..d27056e443 --- /dev/null +++ b/src/course-outline/configure-modal/ConfigureModal.test.jsx @@ -0,0 +1,159 @@ +import React from 'react'; +import { render, fireEvent } from '@testing-library/react'; +import { IntlProvider } from '@edx/frontend-platform/i18n'; +import { useSelector } from 'react-redux'; +import { initializeMockApp } from '@edx/frontend-platform'; +import MockAdapter from 'axios-mock-adapter'; +import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; +import { AppProvider } from '@edx/frontend-platform/react'; + +import initializeStore from '../../store'; +import ConfigureModal from './ConfigureModal'; +import messages from './messages'; + +// eslint-disable-next-line no-unused-vars +let axiosMock; +let store; +const mockPathname = '/foo-bar'; + +jest.mock('react-redux', () => ({ + ...jest.requireActual('react-redux'), + useSelector: jest.fn(), +})); + +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useLocation: () => ({ + pathname: mockPathname, + }), +})); + +const currentSectionMock = { + displayName: 'Section1', + childInfo: { + displayName: 'Subsection', + children: [ + { + displayName: 'Subsection 1', + id: 1, + childInfo: { + displayName: 'Unit', + children: [ + { + id: 11, + displayName: 'Subsection_1 Unit 1', + }, + ], + }, + }, + { + displayName: 'Subsection 2', + id: 2, + childInfo: { + displayName: 'Unit', + children: [ + { + id: 21, + displayName: 'Subsection_2 Unit 1', + }, + ], + }, + }, + { + displayName: 'Subsection 3', + id: 3, + childInfo: { + children: [], + }, + }, + ], + }, +}; + +const onCloseMock = jest.fn(); +const onConfigureSubmitMock = jest.fn(); + +const renderComponent = () => render( + + + + , + , +); + +describe('', () => { + beforeEach(() => { + initializeMockApp({ + authenticatedUser: { + userId: 3, + username: 'abc123', + administrator: true, + roles: [], + }, + }); + + store = initializeStore(); + axiosMock = new MockAdapter(getAuthenticatedHttpClient()); + useSelector.mockReturnValue(currentSectionMock); + }); + + it('renders ConfigureModal component correctly', () => { + const { getByText, getByRole } = renderComponent(); + expect(getByText(`${currentSectionMock.displayName} Settings`)).toBeInTheDocument(); + expect(getByText(messages.basicTabTitle.defaultMessage)).toBeInTheDocument(); + expect(getByText(messages.visibilityTabTitle.defaultMessage)).toBeInTheDocument(); + expect(getByText(messages.releaseDate.defaultMessage)).toBeInTheDocument(); + expect(getByText(messages.releaseTimeUTC.defaultMessage)).toBeInTheDocument(); + expect(getByRole('button', { name: messages.cancelButton.defaultMessage })).toBeInTheDocument(); + expect(getByRole('button', { name: messages.saveButton.defaultMessage })).toBeInTheDocument(); + }); + + it('switches to the Visibility tab and renders correctly', () => { + const { getByRole, getByText } = renderComponent(); + + const visibilityTab = getByRole('tab', { name: messages.visibilityTabTitle.defaultMessage }); + fireEvent.click(visibilityTab); + expect(getByText(messages.sectionVisibility.defaultMessage)).toBeInTheDocument(); + expect(getByText(messages.hideFromLearners.defaultMessage)).toBeInTheDocument(); + }); + + it('disables the Save button and enables it if there is a change', () => { + const { getByRole, getByPlaceholderText, getByTestId } = renderComponent(); + + const saveButton = getByRole('button', { name: messages.saveButton.defaultMessage }); + expect(saveButton).toBeDisabled(); + + const input = getByPlaceholderText('MM/DD/YYYY'); + fireEvent.change(input, { target: { value: '12/15/2023' } }); + + const visibilityTab = getByRole('tab', { name: messages.visibilityTabTitle.defaultMessage }); + fireEvent.click(visibilityTab); + const checkbox = getByTestId('visibility-checkbox'); + fireEvent.click(checkbox); + expect(saveButton).not.toBeDisabled(); + }); + + it('calls OnConfigureSubmit upon save', () => { + const { getByRole, getByPlaceholderText } = renderComponent(); + + const saveButton = getByRole('button', { name: messages.saveButton.defaultMessage }); + const timeInput = getByPlaceholderText('HH:MM'); + + fireEvent.change(timeInput, { target: { value: '10:10' } }); + fireEvent.click(saveButton); + + expect(onConfigureSubmitMock).toHaveBeenCalledTimes(1); + }); + + it('calls the onClose function when the cancel button is clicked', () => { + const { getByRole } = renderComponent(); + + const cancelButton = getByRole('button', { name: messages.cancelButton.defaultMessage }); + fireEvent.click(cancelButton); + expect(onCloseMock).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/course-outline/configure-modal/VisibilityTab.jsx b/src/course-outline/configure-modal/VisibilityTab.jsx new file mode 100644 index 0000000000..033f58018e --- /dev/null +++ b/src/course-outline/configure-modal/VisibilityTab.jsx @@ -0,0 +1,38 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { Alert, Form } from '@edx/paragon'; +import { FormattedMessage, injectIntl } from '@edx/frontend-platform/i18n'; +import messages from './messages'; + +const VisibilityTab = ({ isVisibleToStaffOnly, setIsVisibleToStaffOnly, showWarning }) => { + const handleChange = (e) => { + setIsVisibleToStaffOnly(e.target.checked); + }; + + return ( + <> +

+
+ + + + {showWarning && ( + <> +
+ + + + + + )} + + ); +}; + +VisibilityTab.propTypes = { + isVisibleToStaffOnly: PropTypes.bool.isRequired, + showWarning: PropTypes.bool.isRequired, + setIsVisibleToStaffOnly: 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 new file mode 100644 index 0000000000..3fd9f50bc8 --- /dev/null +++ b/src/course-outline/configure-modal/messages.js @@ -0,0 +1,50 @@ +import { defineMessages } from '@edx/frontend-platform/i18n'; + +const messages = defineMessages({ + title: { + id: 'course-authoring.course-outline.configure-modal.title', + defaultMessage: '{title} Settings', + }, + basicTabTitle: { + id: 'course-authoring.course-outline.configure-modal.basic-tab.title', + defaultMessage: 'Basic', + }, + releaseDateAndTime: { + id: 'course-authoring.course-outline.configure-modal.basic-tab.release-date-and-time', + defaultMessage: 'Release Date and Time', + }, + releaseDate: { + id: 'course-authoring.course-outline.configure-modal.basic-tab.release-date', + defaultMessage: 'Release Date:', + }, + releaseTimeUTC: { + id: 'course-authoring.course-outline.configure-modal.basic-tab.release-time-UTC', + defaultMessage: 'Release Time in UTC:', + }, + visibilityTabTitle: { + id: 'course-authoring.course-outline.configure-modal.visibility-tab.title', + defaultMessage: 'Visibility', + }, + sectionVisibility: { + id: 'course-authoring.course-outline.configure-modal.visibility-tab.section-visibility', + defaultMessage: 'Section Visibility', + }, + hideFromLearners: { + id: 'course-authoring.course-outline.configure-modal.visibility-tab.hide-from-learners', + defaultMessage: 'Hide from learners', + }, + visibilityWarning: { + id: 'course-authoring.course-outline.configure-modal.visibility-tab.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.', + }, + cancelButton: { + id: 'course-authoring.course-outline.configure-modal.button.cancel', + defaultMessage: 'Cancel', + }, + saveButton: { + id: 'course-authoring.course-outline.configure-modal.button.label', + defaultMessage: 'Save', + }, +}); + +export default messages; diff --git a/src/course-outline/data/api.js b/src/course-outline/data/api.js index 11bce0b17a..52ea3b86c7 100644 --- a/src/course-outline/data/api.js +++ b/src/course-outline/data/api.js @@ -152,6 +152,25 @@ export async function publishCourseSection(sectionId) { return data; } +/** + * Configure course section + * @param {string} sectionId + * @returns {Promise} + */ +export async function configureCourseSection(sectionId, isVisibleToStaffOnly, startDatetime) { + const { data } = await getAuthenticatedHttpClient() + .post(getUpdateCourseSectionApiUrl(sectionId), { + publish: 'republish', + metadata: { + // The backend expects metadata.visible_to_staff_only to either true or null + visible_to_staff_only: isVisibleToStaffOnly ? true : null, + start: startDatetime, + }, + }); + + return data; +} + /** * Edit course section * @param {string} sectionId diff --git a/src/course-outline/data/thunk.js b/src/course-outline/data/thunk.js index 72d5eedede..39cd346980 100644 --- a/src/course-outline/data/thunk.js +++ b/src/course-outline/data/thunk.js @@ -20,6 +20,7 @@ import { getCourseOutlineIndex, getCourseSection, publishCourseSection, + configureCourseSection, restartIndexingOnCourse, updateCourseSectionHighlights, } from './api'; @@ -177,6 +178,26 @@ export function publishCourseSectionQuery(sectionId) { }; } +export function configureCourseSectionQuery(sectionId, isVisibleToStaffOnly, startDatetime) { + return async (dispatch) => { + dispatch(updateSavingStatus({ status: RequestStatus.PENDING })); + dispatch(showProcessingNotification(NOTIFICATION_MESSAGES.saving)); + + try { + await configureCourseSection(sectionId, isVisibleToStaffOnly, startDatetime).then(async (result) => { + if (result) { + await dispatch(fetchCourseSectionQuery(sectionId)); + dispatch(hideProcessingNotification()); + dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL })); + } + }); + } catch (error) { + dispatch(hideProcessingNotification()); + dispatch(updateSavingStatus({ status: RequestStatus.FAILED })); + } + }; +} + export function editCourseSectionQuery(sectionId, displayName) { return async (dispatch) => { dispatch(updateSavingStatus({ status: RequestStatus.PENDING })); diff --git a/src/course-outline/hooks.jsx b/src/course-outline/hooks.jsx index 33ca5a0f55..18eb443ed5 100644 --- a/src/course-outline/hooks.jsx +++ b/src/course-outline/hooks.jsx @@ -27,6 +27,7 @@ import { fetchCourseReindexQuery, publishCourseSectionQuery, updateCourseSectionHighlightsQuery, + configureCourseSectionQuery, } from './data/thunk'; const useCourseOutline = ({ courseId }) => { @@ -46,6 +47,7 @@ const useCourseOutline = ({ courseId }) => { const [showErrorAlert, setShowErrorAlert] = useState(false); const [isHighlightsModalOpen, openHighlightsModal, closeHighlightsModal] = useToggle(false); const [isPublishModalOpen, openPublishModal, closePublishModal] = useToggle(false); + const [isConfigureModalOpen, openConfigureModal, closeConfigureModal] = useToggle(false); const [isDeleteModalOpen, openDeleteModal, closeDeleteModal] = useToggle(false); const handleNewSectionSubmit = () => { @@ -96,6 +98,12 @@ const useCourseOutline = ({ courseId }) => { closePublishModal(); }; + const handleConfigureSectionSubmit = (isVisibleToStaffOnly, startDatetime) => { + dispatch(configureCourseSectionQuery(currentSection.id, isVisibleToStaffOnly, startDatetime)); + + closeConfigureModal(); + }; + const handleEditSectionSubmit = (sectionId, displayName) => { dispatch(editCourseSectionQuery(sectionId, displayName)); }; @@ -137,10 +145,14 @@ const useCourseOutline = ({ courseId }) => { isPublishModalOpen, openPublishModal, closePublishModal, + isConfigureModalOpen, + openConfigureModal, + closeConfigureModal, headerNavigationsActions, handleEnableHighlightsSubmit, handleHighlightsFormSubmit, handlePublishSectionSubmit, + handleConfigureSectionSubmit, handleEditSectionSubmit, statusBarData, isEnableHighlightsModalOpen, diff --git a/src/course-outline/section-card/SectionCard.jsx b/src/course-outline/section-card/SectionCard.jsx index 1a8e349392..5d37769943 100644 --- a/src/course-outline/section-card/SectionCard.jsx +++ b/src/course-outline/section-card/SectionCard.jsx @@ -16,6 +16,7 @@ const SectionCard = ({ children, onOpenHighlightsModal, onOpenPublishModal, + onOpenConfigureModal, onEditSectionSubmit, savingStatus, onOpenDeleteModal, @@ -89,6 +90,7 @@ const SectionCard = ({ onExpand={handleExpandContent} onClickMenuButton={handleClickMenuButton} onClickPublish={onOpenPublishModal} + onClickConfigure={onOpenConfigureModal} onClickEdit={openForm} onClickDelete={onOpenDeleteModal} isFormOpen={isFormOpen} @@ -149,6 +151,7 @@ SectionCard.propTypes = { children: PropTypes.node, onOpenHighlightsModal: PropTypes.func.isRequired, onOpenPublishModal: PropTypes.func.isRequired, + onOpenConfigureModal: PropTypes.func.isRequired, onEditSectionSubmit: PropTypes.func.isRequired, savingStatus: PropTypes.string.isRequired, onOpenDeleteModal: PropTypes.func.isRequired, diff --git a/src/data/constants.js b/src/data/constants.js index d91b6bfb5d..d5af4e41df 100644 --- a/src/data/constants.js +++ b/src/data/constants.js @@ -40,3 +40,8 @@ export const DivisionSchemes = { NONE: 'none', COHORT: 'cohort', }; + +export const VisibilityTypes = { + STAFF_ONLY: 'staff_only', + HIDE_AFTER_DUE: 'hide_after_due', +};