From 7286b21f5aebb381982e3e98c110625b62d63e41 Mon Sep 17 00:00:00 2001 From: Moncef Abboud Date: Thu, 30 Nov 2023 19:00:06 +0100 Subject: [PATCH] feat: add Section Configure --- src/course-outline/CourseOutline.jsx | 11 ++ src/course-outline/CourseOutline.scss | 1 + src/course-outline/CourseOutline.test.jsx | 50 +++++++ src/course-outline/card-header/CardHeader.jsx | 4 +- .../configure-modal/BasicTab.jsx | 43 ++++++ .../configure-modal/ConfigureModal.jsx | 95 ++++++++++++ .../configure-modal/ConfigureModal.scss | 12 ++ .../configure-modal/ConfigureModal.test.jsx | 139 ++++++++++++++++++ .../configure-modal/VisibilityTab.jsx | 38 +++++ .../configure-modal/messages.js | 50 +++++++ src/course-outline/data/api.js | 19 +++ src/course-outline/data/thunk.js | 21 +++ src/course-outline/hooks.jsx | 12 ++ .../section-card/SectionCard.jsx | 3 + src/data/constants.js | 5 + 15 files changed, 502 insertions(+), 1 deletion(-) create mode 100644 src/course-outline/configure-modal/BasicTab.jsx create mode 100644 src/course-outline/configure-modal/ConfigureModal.jsx create mode 100644 src/course-outline/configure-modal/ConfigureModal.scss create mode 100644 src/course-outline/configure-modal/ConfigureModal.test.jsx create mode 100644 src/course-outline/configure-modal/VisibilityTab.jsx create mode 100644 src/course-outline/configure-modal/messages.js diff --git a/src/course-outline/CourseOutline.jsx b/src/course-outline/CourseOutline.jsx index a33beb483..dd9c5f65d 100644 --- a/src/course-outline/CourseOutline.jsx +++ b/src/course-outline/CourseOutline.jsx @@ -30,6 +30,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'; @@ -52,11 +53,14 @@ const CourseOutline = ({ courseId }) => { isDisabledReindexButton, isHighlightsModalOpen, isPublishModalOpen, + isConfigureModalOpen, isDeleteModalOpen, closeHighlightsModal, closePublishModal, + closeConfigureModal, closeDeleteModal, openPublishModal, + openConfigureModal, openDeleteModal, headerNavigationsActions, openEnableHighlightsModal, @@ -66,6 +70,7 @@ const CourseOutline = ({ courseId }) => { handleOpenHighlightsModal, handleHighlightsFormSubmit, handlePublishSectionSubmit, + handleConfigureSectionSubmit, handleEditSectionSubmit, handleDeleteSectionSubmit, handleDuplicateSectionSubmit, @@ -145,6 +150,7 @@ const CourseOutline = ({ courseId }) => { savingStatus={savingStatus} onOpenHighlightsModal={handleOpenHighlightsModal} onOpenPublishModal={openPublishModal} + onOpenConfigureModal={openConfigureModal} onOpenDeleteModal={openDeleteModal} onEditSectionSubmit={handleEditSectionSubmit} onDuplicateSubmit={handleDuplicateSectionSubmit} @@ -190,6 +196,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); + + 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' } }); + + axiosMock + .onGet(getXBlockApiUrl(section.id)) + .reply(200, { + ...section, + start: newReleaseDate, + }); + + 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 a6d87171a..eeedf676b 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 000000000..ffdfc81c5 --- /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 000000000..cedcf0198 --- /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 000000000..1fad13926 --- /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 000000000..b9a331a4e --- /dev/null +++ b/src/course-outline/configure-modal/ConfigureModal.test.jsx @@ -0,0 +1,139 @@ +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(); + }); +}); diff --git a/src/course-outline/configure-modal/VisibilityTab.jsx b/src/course-outline/configure-modal/VisibilityTab.jsx new file mode 100644 index 000000000..033f58018 --- /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 000000000..3fd9f50bc --- /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 1451c908d..27209e06e 100644 --- a/src/course-outline/data/api.js +++ b/src/course-outline/data/api.js @@ -215,6 +215,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 72d5eeded..39cd34698 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 33ca5a0f5..18eb443ed 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 72dc70aa2..245825591 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 d379233ac..5191ea1df 100644 --- a/src/data/constants.js +++ b/src/data/constants.js @@ -41,3 +41,8 @@ export const DivisionSchemes = { NONE: 'none', COHORT: 'cohort', }; + +export const VisibilityTypes = { + STAFF_ONLY: 'staff_only', + HIDE_AFTER_DUE: 'hide_after_due', +};