From 6b75429cc062e5e6b8c7347947b6ba19874f31c5 Mon Sep 17 00:00:00 2001 From: Navin Karkera Date: Tue, 3 Feb 2026 21:10:36 +0530 Subject: [PATCH 01/48] feat: unit settings tab in outline --- .../info-sidebar/CourseInfoSidebar.tsx | 1 + .../info-sidebar/UnitInfoSidebar.tsx | 33 +++- src/course-unit/data/api.ts | 10 +- src/course-unit/data/apiHooks.ts | 49 +++++- .../unit-info/GenericUnitInfoSettings.tsx | 143 ++++++++++++++++++ .../unit-info/UnitInfoSidebar.tsx | 115 ++------------ 6 files changed, 239 insertions(+), 112 deletions(-) create mode 100644 src/course-unit/unit-sidebar/unit-info/GenericUnitInfoSettings.tsx diff --git a/src/course-outline/outline-sidebar/info-sidebar/CourseInfoSidebar.tsx b/src/course-outline/outline-sidebar/info-sidebar/CourseInfoSidebar.tsx index a7344952ea..c695f88936 100644 --- a/src/course-outline/outline-sidebar/info-sidebar/CourseInfoSidebar.tsx +++ b/src/course-outline/outline-sidebar/info-sidebar/CourseInfoSidebar.tsx @@ -175,3 +175,4 @@ export const CourseInfoSidebar = () => { ); }; + diff --git a/src/course-outline/outline-sidebar/info-sidebar/UnitInfoSidebar.tsx b/src/course-outline/outline-sidebar/info-sidebar/UnitInfoSidebar.tsx index 2c4a97b1f8..fdd6b2cc94 100644 --- a/src/course-outline/outline-sidebar/info-sidebar/UnitInfoSidebar.tsx +++ b/src/course-outline/outline-sidebar/info-sidebar/UnitInfoSidebar.tsx @@ -11,7 +11,7 @@ import { getItemIcon } from '@src/generic/block-type-utils'; import { SidebarTitle } from '@src/generic/sidebar'; -import { useCourseItemData } from '@src/course-outline/data/apiHooks'; +import { courseOutlineQueryKeys, useCourseItemData } from '@src/course-outline/data/apiHooks'; import Loading from '@src/generic/Loading'; import { useCourseAuthoringContext } from '@src/CourseAuthoringContext'; import XBlockContainerIframe from '@src/course-unit/xblock-container-iframe'; @@ -21,15 +21,40 @@ import { useOutlineSidebarContext } from '../OutlineSidebarContext'; import { PublishButon } from './PublishButon'; import messages from '../messages'; import { InfoSection } from './InfoSection'; +import { GenericUnitInfoSettings } from '@src/course-unit/unit-sidebar/unit-info/GenericUnitInfoSettings'; +import { useQueryClient } from '@tanstack/react-query'; interface Props { unitId: string; } +const UnitSettingsTab = ({ unitId }: Props) => { + const queryClient = useQueryClient(); + const { data: unitData, isPending } = useCourseItemData(unitId); + + if (isPending || !unitData) { + return + } + + const onUpdate = () => { + queryClient.invalidateQueries({ queryKey: courseOutlineQueryKeys.courseItemId(unitId)}); + } + + return ( + + ); +} + export const UnitSidebar = ({ unitId }: Props) => { const intl = useIntl(); const [tab, setTab] = useState<'preview' | 'info' | 'settings'>('info'); - const { data: unitData, isLoading } = useCourseItemData(unitId); + const { data: unitData, isPending } = useCourseItemData(unitId); const { selectedContainerState, clearSelection } = useOutlineSidebarContext(); const { openPublishModal, getUnitUrl, courseId } = useCourseAuthoringContext(); @@ -43,7 +68,7 @@ export const UnitSidebar = ({ unitId }: Props) => { } }; - if (isLoading) { + if (isPending) { return ; } @@ -98,7 +123,7 @@ export const UnitSidebar = ({ unitId }: Props) => { -
Settings
+
diff --git a/src/course-unit/data/api.ts b/src/course-unit/data/api.ts index d4474ed5fb..d54eae267a 100644 --- a/src/course-unit/data/api.ts +++ b/src/course-unit/data/api.ts @@ -45,13 +45,19 @@ export async function getVerticalData(unitId: string): Promise { * Handles the visibility and data of a course unit, such as publishing, resetting to default values, * and toggling visibility to students. */ -export async function handleCourseUnitVisibilityAndData( +export async function handleCourseUnitVisibilityAndData({ + unitId, + type, + isVisible, + isDiscussionEnabled, + groupAccess, +}: { unitId: string, type: string, // The action type (e.g., PUBLISH_TYPES.discardChanges). isVisible: boolean, // The visibility status for students. isDiscussionEnabled: boolean, groupAccess: Record | null, -): Promise { +}): Promise { const body = { publish: groupAccess ? null : type, ...(type === PUBLISH_TYPES.republish ? { diff --git a/src/course-unit/data/apiHooks.ts b/src/course-unit/data/apiHooks.ts index e07fd10004..850ff849cd 100644 --- a/src/course-unit/data/apiHooks.ts +++ b/src/course-unit/data/apiHooks.ts @@ -1,6 +1,19 @@ -import { useMutation } from '@tanstack/react-query'; +import { courseOutlineQueryKeys } from '@src/course-outline/data/apiHooks'; +import { fetchCourseSectionVerticalDataSuccess, updateCourseVerticalChildren, updateQueryPendingStatus, updateSavingStatus } from '@src/course-unit/data/slice'; +import { getNotificationMessage } from './utils'; +import { RequestStatus } from '@src/data/constants'; +import { hideProcessingNotification, showProcessingNotification } from '@src/generic/processing-notification/data/slice'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { useDispatch } from 'react-redux'; -import { acceptLibraryBlockChanges, ignoreLibraryBlockChanges } from './api'; +import { + acceptLibraryBlockChanges, + getCourseContainerChildren, + getVerticalData, + handleCourseUnitVisibilityAndData, + ignoreLibraryBlockChanges, +} from './api'; +import { handleResponseErrors } from '@src/generic/saving-error-alert'; /** * Hook that provides a "mutation" that can be used to accept library block changes. @@ -17,3 +30,35 @@ export const useAcceptLibraryBlockChanges = () => useMutation({ export const useIgnoreLibraryBlockChanges = () => useMutation({ mutationFn: ignoreLibraryBlockChanges, }); + +export const useEditCourseUnitVisibilityAndData = () => { + const queryClient = useQueryClient(); + const dispatch = useDispatch(); + return useMutation({ + mutationFn: handleCourseUnitVisibilityAndData, + onMutate: (variables) => { + dispatch(updateSavingStatus({ status: RequestStatus.PENDING })); + dispatch(updateQueryPendingStatus(true)); + dispatch(showProcessingNotification( + getNotificationMessage(variables.type, variables.isVisible, true) + )); + }, + onSuccess: async (_data, variables) => { + const courseSectionVerticalData = await getVerticalData(variables.unitId); + dispatch(fetchCourseSectionVerticalDataSuccess(courseSectionVerticalData)); + const courseVerticalChildrenData = await getCourseContainerChildren(variables.unitId); + dispatch(updateCourseVerticalChildren(courseVerticalChildrenData)); + dispatch(hideProcessingNotification()); + dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL })); + }, + onSettled: async (_data, _err, variables) => { + queryClient.invalidateQueries({ + queryKey: courseOutlineQueryKeys.courseItemId(variables.unitId), + }); + }, + onError: (error) => { + dispatch(hideProcessingNotification()); + handleResponseErrors(error, dispatch, updateSavingStatus); + }, + }); +} diff --git a/src/course-unit/unit-sidebar/unit-info/GenericUnitInfoSettings.tsx b/src/course-unit/unit-sidebar/unit-info/GenericUnitInfoSettings.tsx new file mode 100644 index 0000000000..b797d84e59 --- /dev/null +++ b/src/course-unit/unit-sidebar/unit-info/GenericUnitInfoSettings.tsx @@ -0,0 +1,143 @@ +import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n'; +import { Button, ButtonGroup } from '@openedx/paragon'; +import { PUBLISH_TYPES, UNIT_VISIBILITY_STATES } from '@src/course-unit/constants'; +import { editCourseUnitVisibilityAndData } from '@src/course-unit/data/thunk'; +import { UserPartitionInfoTypes } from '@src/data/types'; +import { AccessEditComponent, DiscussionEditComponent } from '@src/generic/configure-modal/UnitTab'; +import { SidebarContent, SidebarSection } from '@src/generic/sidebar'; +import { Form, Formik } from 'formik'; +import { useMemo } from 'react'; +import { useDispatch } from 'react-redux'; +import messages from './messages'; +import configureMessages from '@src/generic/configure-modal/messages'; +import { useEditCourseUnitVisibilityAndData } from '@src/course-unit/data/apiHooks'; + +interface UnitInfoSettingsProps { + id: string; + visibilityState: string; + discussionEnabled?: boolean; + userPartitionInfo?: UserPartitionInfoTypes; + updateCallback?: () => void; +} + +/** + * Generic Component with forms to edit unit settings. + * + * It is used in settings tab of unit sidebar in both outline and unit page + */ +export const GenericUnitInfoSettings = (props: UnitInfoSettingsProps) => { + const intl = useIntl(); + const { + id, + visibilityState, + discussionEnabled, + userPartitionInfo, + } = props; + + const visibleToStaffOnly = visibilityState === UNIT_VISIBILITY_STATES.staffOnly; + const mutateFn = useEditCourseUnitVisibilityAndData(); + + const handleUpdate = async ( + isVisible: boolean, + groupAccess: Record | null, + isDiscussionEnabled?: boolean, + ) => { + // oxlint-disable-next-line @typescript-eslint/await-thenable - this dispatch() IS returning a promise. + await mutateFn.mutateAsync({ + unitId: id, + type: PUBLISH_TYPES.republish, + isVisible, + groupAccess, + isDiscussionEnabled: !!isDiscussionEnabled, + }, { + onSuccess: () => props.updateCallback?.(), + }); + }; + + const handleSaveGroups = async (data, { resetForm }) => { + const groupAccess = {}; + if (userPartitionInfo && data.selectedPartitionIndex >= 0) { + const partitionId = userPartitionInfo.selectablePartitions[data.selectedPartitionIndex].id; + groupAccess[partitionId] = data.selectedGroups.map(g => parseInt(g, 10)); + } + await handleUpdate(visibleToStaffOnly, groupAccess, !!discussionEnabled); + resetForm({ values: data }); + }; + + /* istanbul ignore next */ + const getSelectedGroups = () => { + if (userPartitionInfo && userPartitionInfo.selectedPartitionIndex >= 0) { + return userPartitionInfo.selectablePartitions[userPartitionInfo?.selectedPartitionIndex] + ?.groups + .filter(({ selected }) => selected) + // eslint-disable-next-line @typescript-eslint/no-shadow + .map(({ id }) => `${id}`) + || []; + } + return []; + }; + + const initialValues = useMemo(() => ( + { + selectedPartitionIndex: userPartitionInfo?.selectedPartitionIndex, + selectedGroups: getSelectedGroups(), + } + ), [userPartitionInfo]); + + return ( + + + + + + + + + + {({ + values, setFieldValue, dirty, + }) => ( +
+ + {dirty && ( + + )} + + )} +
+
+ + handleUpdate(visibleToStaffOnly, null, e.target.checked)} + /> + +
+ ); +}; diff --git a/src/course-unit/unit-sidebar/unit-info/UnitInfoSidebar.tsx b/src/course-unit/unit-sidebar/unit-info/UnitInfoSidebar.tsx index 4bcdc29157..3d0d07b973 100644 --- a/src/course-unit/unit-sidebar/unit-info/UnitInfoSidebar.tsx +++ b/src/course-unit/unit-sidebar/unit-info/UnitInfoSidebar.tsx @@ -19,6 +19,7 @@ import { editCourseUnitVisibilityAndData } from '@src/course-unit/data/thunk'; import PublishControls from './PublishControls'; import { useUnitSidebarContext } from '../UnitSidebarContext'; import messages from './messages'; +import { GenericUnitInfoSettings } from '@src/course-unit/unit-sidebar/unit-info/GenericUnitInfoSettings'; /** * Component to show unit details: Publish status, Component counts and Content Tags. @@ -70,9 +71,7 @@ const UnitInfoDetails = () => { * * It's using in the settings tab of the unit info sidebar. */ -const UnitInfoSettings = () => { - const dispatch = useDispatch(); - const intl = useIntl(); +export const UnitInfoSettings = () => { const { sendMessageToIframe } = useIframe(); const { id, @@ -81,110 +80,18 @@ const UnitInfoSettings = () => { userPartitionInfo, } = useSelector(getCourseUnitData); - const visibleToStaffOnly = visibilityState === UNIT_VISIBILITY_STATES.staffOnly; - - const handleUpdate = async ( - isVisible: boolean, - groupAccess: Record | null, - isDiscussionEnabled: boolean, - ) => { - // oxlint-disable-next-line @typescript-eslint/await-thenable - this dispatch() IS returning a promise. - await dispatch(editCourseUnitVisibilityAndData( - id, - PUBLISH_TYPES.republish, - isVisible, - groupAccess, - isDiscussionEnabled, - () => sendMessageToIframe(messageTypes.refreshXBlock, null), - id, - )); - }; - - const handleSaveGroups = async (data, { resetForm }) => { - const groupAccess = {}; - if (data.selectedPartitionIndex >= 0) { - const partitionId = userPartitionInfo.selectablePartitions[data.selectedPartitionIndex].id; - groupAccess[partitionId] = data.selectedGroups.map(g => parseInt(g, 10)); - } - await handleUpdate(visibleToStaffOnly, groupAccess, discussionEnabled); - resetForm({ values: data }); - }; - - /* istanbul ignore next */ - const getSelectedGroups = () => { - if (userPartitionInfo?.selectedPartitionIndex >= 0) { - return userPartitionInfo?.selectablePartitions[userPartitionInfo?.selectedPartitionIndex] - ?.groups - .filter(({ selected }) => selected) - // eslint-disable-next-line @typescript-eslint/no-shadow - .map(({ id }) => `${id}`) - || []; - } - return []; + const updateCallback = () => { + sendMessageToIframe(messageTypes.refreshXBlock, null); }; - const initialValues = useMemo(() => ( - { - selectedPartitionIndex: userPartitionInfo?.selectedPartitionIndex, - selectedGroups: getSelectedGroups(), - } - ), [userPartitionInfo]); - return ( - - - - - - - - - - {({ - values, setFieldValue, dirty, - }) => ( -
- - {dirty && ( - - )} - - )} -
-
- - handleUpdate(visibleToStaffOnly, null, e.target.checked)} - /> - -
+ ); }; From c73592f8287db6249893d78aaa6f263eee84dfed Mon Sep 17 00:00:00 2001 From: Navin Karkera Date: Fri, 13 Mar 2026 12:09:22 +0530 Subject: [PATCH 02/48] fix: lint issues --- .../info-sidebar/CourseInfoSidebar.tsx | 1 - .../outline-sidebar/info-sidebar/UnitInfoSidebar.tsx | 12 ++++++------ src/course-unit/data/apiHooks.ts | 12 +++++++----- .../unit-info/GenericUnitInfoSettings.tsx | 2 +- .../unit-sidebar/unit-info/UnitInfoSidebar.tsx | 2 +- 5 files changed, 15 insertions(+), 14 deletions(-) diff --git a/src/course-outline/outline-sidebar/info-sidebar/CourseInfoSidebar.tsx b/src/course-outline/outline-sidebar/info-sidebar/CourseInfoSidebar.tsx index c695f88936..a7344952ea 100644 --- a/src/course-outline/outline-sidebar/info-sidebar/CourseInfoSidebar.tsx +++ b/src/course-outline/outline-sidebar/info-sidebar/CourseInfoSidebar.tsx @@ -175,4 +175,3 @@ export const CourseInfoSidebar = () => { ); }; - diff --git a/src/course-outline/outline-sidebar/info-sidebar/UnitInfoSidebar.tsx b/src/course-outline/outline-sidebar/info-sidebar/UnitInfoSidebar.tsx index fdd6b2cc94..5a61e4b12a 100644 --- a/src/course-outline/outline-sidebar/info-sidebar/UnitInfoSidebar.tsx +++ b/src/course-outline/outline-sidebar/info-sidebar/UnitInfoSidebar.tsx @@ -17,12 +17,12 @@ import { useCourseAuthoringContext } from '@src/CourseAuthoringContext'; import XBlockContainerIframe from '@src/course-unit/xblock-container-iframe'; import { IframeProvider } from '@src/generic/hooks/context/iFrameContext'; import { Link } from 'react-router-dom'; +import { GenericUnitInfoSettings } from '@src/course-unit/unit-sidebar/unit-info/GenericUnitInfoSettings'; +import { useQueryClient } from '@tanstack/react-query'; import { useOutlineSidebarContext } from '../OutlineSidebarContext'; import { PublishButon } from './PublishButon'; import messages from '../messages'; import { InfoSection } from './InfoSection'; -import { GenericUnitInfoSettings } from '@src/course-unit/unit-sidebar/unit-info/GenericUnitInfoSettings'; -import { useQueryClient } from '@tanstack/react-query'; interface Props { unitId: string; @@ -33,12 +33,12 @@ const UnitSettingsTab = ({ unitId }: Props) => { const { data: unitData, isPending } = useCourseItemData(unitId); if (isPending || !unitData) { - return + return ; } const onUpdate = () => { - queryClient.invalidateQueries({ queryKey: courseOutlineQueryKeys.courseItemId(unitId)}); - } + queryClient.invalidateQueries({ queryKey: courseOutlineQueryKeys.courseItemId(unitId) }); + }; return ( { updateCallback={onUpdate} /> ); -} +}; export const UnitSidebar = ({ unitId }: Props) => { const intl = useIntl(); diff --git a/src/course-unit/data/apiHooks.ts b/src/course-unit/data/apiHooks.ts index 850ff849cd..5455558b3d 100644 --- a/src/course-unit/data/apiHooks.ts +++ b/src/course-unit/data/apiHooks.ts @@ -1,11 +1,13 @@ import { courseOutlineQueryKeys } from '@src/course-outline/data/apiHooks'; -import { fetchCourseSectionVerticalDataSuccess, updateCourseVerticalChildren, updateQueryPendingStatus, updateSavingStatus } from '@src/course-unit/data/slice'; -import { getNotificationMessage } from './utils'; +import { + fetchCourseSectionVerticalDataSuccess, updateCourseVerticalChildren, updateQueryPendingStatus, updateSavingStatus, +} from '@src/course-unit/data/slice'; import { RequestStatus } from '@src/data/constants'; import { hideProcessingNotification, showProcessingNotification } from '@src/generic/processing-notification/data/slice'; import { useMutation, useQueryClient } from '@tanstack/react-query'; import { useDispatch } from 'react-redux'; +import { handleResponseErrors } from '@src/generic/saving-error-alert'; import { acceptLibraryBlockChanges, getCourseContainerChildren, @@ -13,7 +15,7 @@ import { handleCourseUnitVisibilityAndData, ignoreLibraryBlockChanges, } from './api'; -import { handleResponseErrors } from '@src/generic/saving-error-alert'; +import { getNotificationMessage } from './utils'; /** * Hook that provides a "mutation" that can be used to accept library block changes. @@ -40,7 +42,7 @@ export const useEditCourseUnitVisibilityAndData = () => { dispatch(updateSavingStatus({ status: RequestStatus.PENDING })); dispatch(updateQueryPendingStatus(true)); dispatch(showProcessingNotification( - getNotificationMessage(variables.type, variables.isVisible, true) + getNotificationMessage(variables.type, variables.isVisible, true), )); }, onSuccess: async (_data, variables) => { @@ -61,4 +63,4 @@ export const useEditCourseUnitVisibilityAndData = () => { handleResponseErrors(error, dispatch, updateSavingStatus); }, }); -} +}; diff --git a/src/course-unit/unit-sidebar/unit-info/GenericUnitInfoSettings.tsx b/src/course-unit/unit-sidebar/unit-info/GenericUnitInfoSettings.tsx index b797d84e59..06884bb809 100644 --- a/src/course-unit/unit-sidebar/unit-info/GenericUnitInfoSettings.tsx +++ b/src/course-unit/unit-sidebar/unit-info/GenericUnitInfoSettings.tsx @@ -8,9 +8,9 @@ import { SidebarContent, SidebarSection } from '@src/generic/sidebar'; import { Form, Formik } from 'formik'; import { useMemo } from 'react'; import { useDispatch } from 'react-redux'; -import messages from './messages'; import configureMessages from '@src/generic/configure-modal/messages'; import { useEditCourseUnitVisibilityAndData } from '@src/course-unit/data/apiHooks'; +import messages from './messages'; interface UnitInfoSettingsProps { id: string; diff --git a/src/course-unit/unit-sidebar/unit-info/UnitInfoSidebar.tsx b/src/course-unit/unit-sidebar/unit-info/UnitInfoSidebar.tsx index 3d0d07b973..eb051a56fb 100644 --- a/src/course-unit/unit-sidebar/unit-info/UnitInfoSidebar.tsx +++ b/src/course-unit/unit-sidebar/unit-info/UnitInfoSidebar.tsx @@ -16,10 +16,10 @@ import { Form, Formik } from 'formik'; import { getCourseUnitData, getCourseVerticalChildren } from '@src/course-unit/data/selectors'; import { messageTypes, PUBLISH_TYPES, UNIT_VISIBILITY_STATES } from '@src/course-unit/constants'; import { editCourseUnitVisibilityAndData } from '@src/course-unit/data/thunk'; +import { GenericUnitInfoSettings } from '@src/course-unit/unit-sidebar/unit-info/GenericUnitInfoSettings'; import PublishControls from './PublishControls'; import { useUnitSidebarContext } from '../UnitSidebarContext'; import messages from './messages'; -import { GenericUnitInfoSettings } from '@src/course-unit/unit-sidebar/unit-info/GenericUnitInfoSettings'; /** * Component to show unit details: Publish status, Component counts and Content Tags. From de603d0b38f2189a6bd326e223043673c67d16b1 Mon Sep 17 00:00:00 2001 From: Navin Karkera Date: Fri, 13 Mar 2026 15:19:25 +0530 Subject: [PATCH 03/48] fix: settings tab lost on resize --- .../outline-sidebar/info-sidebar/UnitInfoSidebar.tsx | 2 +- src/generic/sidebar/index.scss | 10 ++++++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/src/course-outline/outline-sidebar/info-sidebar/UnitInfoSidebar.tsx b/src/course-outline/outline-sidebar/info-sidebar/UnitInfoSidebar.tsx index 5a61e4b12a..d35f4aab57 100644 --- a/src/course-outline/outline-sidebar/info-sidebar/UnitInfoSidebar.tsx +++ b/src/course-outline/outline-sidebar/info-sidebar/UnitInfoSidebar.tsx @@ -96,7 +96,7 @@ export const UnitSidebar = ({ unitId }: Props) => {