From 3dae0048d5c94d13692eea8bf94e7c7e7b4868cb Mon Sep 17 00:00:00 2001 From: Navin Karkera Date: Thu, 15 Jan 2026 11:49:03 +0530 Subject: [PATCH 01/60] feat: container sidebar in outline --- ...eInfoSidebar.tsx => CourseInfoSidebar.tsx} | 2 +- .../outline-sidebar/InfoSidebar.tsx | 29 +++++++ .../outline-sidebar/LibraryReferenceCard.tsx | 6 ++ .../outline-sidebar/OutlineSidebarContext.tsx | 14 ++-- .../outline-sidebar/PublishButon.tsx | 25 ++++++ .../outline-sidebar/SectionnfoSidebar.tsx | 78 +++++++++++++++++++ .../outline-sidebar/constants.ts | 28 +++++++ .../outline-sidebar/messages.ts | 15 ++++ .../generic/publish-status-buttons/index.scss | 2 +- .../generic/status-widget/StatusWidget.scss | 2 +- 10 files changed, 192 insertions(+), 9 deletions(-) rename src/course-outline/outline-sidebar/{OutlineInfoSidebar.tsx => CourseInfoSidebar.tsx} (97%) create mode 100644 src/course-outline/outline-sidebar/InfoSidebar.tsx create mode 100644 src/course-outline/outline-sidebar/LibraryReferenceCard.tsx create mode 100644 src/course-outline/outline-sidebar/PublishButon.tsx create mode 100644 src/course-outline/outline-sidebar/SectionnfoSidebar.tsx create mode 100644 src/course-outline/outline-sidebar/constants.ts diff --git a/src/course-outline/outline-sidebar/OutlineInfoSidebar.tsx b/src/course-outline/outline-sidebar/CourseInfoSidebar.tsx similarity index 97% rename from src/course-outline/outline-sidebar/OutlineInfoSidebar.tsx rename to src/course-outline/outline-sidebar/CourseInfoSidebar.tsx index b75dad5a89..ee42c29a9b 100644 --- a/src/course-outline/outline-sidebar/OutlineInfoSidebar.tsx +++ b/src/course-outline/outline-sidebar/CourseInfoSidebar.tsx @@ -12,7 +12,7 @@ import { useCourseDetails } from '../data/apiHooks'; import messages from './messages'; -export const OutlineInfoSidebar = () => { +export const CourseInfoSidebar = () => { const intl = useIntl(); const { courseId } = useCourseAuthoringContext(); const { data: courseDetails } = useCourseDetails(courseId); diff --git a/src/course-outline/outline-sidebar/InfoSidebar.tsx b/src/course-outline/outline-sidebar/InfoSidebar.tsx new file mode 100644 index 0000000000..65c82b0191 --- /dev/null +++ b/src/course-outline/outline-sidebar/InfoSidebar.tsx @@ -0,0 +1,29 @@ +import { useOutlineSidebarContext } from "./OutlineSidebarContext"; +import { CourseInfoSidebar } from "./CourseInfoSidebar" +import { ContainerType, getBlockType } from "@src/generic/key-utils"; +import { SectionSidebar } from "./SectionnfoSidebar"; + +export const InfoSidebar = () => { + const { selectedContainerId } = useOutlineSidebarContext(); + if (!selectedContainerId) { + return ( + + ) + } + const itemType = getBlockType(selectedContainerId); + + switch (itemType) { + case ContainerType.Chapter: + case ContainerType.Section: + return + case ContainerType.Sequential: + case ContainerType.Subsection: + return
Subsection sidebar
; + case ContainerType.Vertical: + case ContainerType.Unit: + return
Unit sidebar
; + default: + return ; + } +} + diff --git a/src/course-outline/outline-sidebar/LibraryReferenceCard.tsx b/src/course-outline/outline-sidebar/LibraryReferenceCard.tsx new file mode 100644 index 0000000000..22578cd6b2 --- /dev/null +++ b/src/course-outline/outline-sidebar/LibraryReferenceCard.tsx @@ -0,0 +1,6 @@ +export const LibraryReferenceCard = () => { + return ( +
LibraryReferenceCard
+ ) +}; + diff --git a/src/course-outline/outline-sidebar/OutlineSidebarContext.tsx b/src/course-outline/outline-sidebar/OutlineSidebarContext.tsx index 2151bc41df..171321520e 100644 --- a/src/course-outline/outline-sidebar/OutlineSidebarContext.tsx +++ b/src/course-outline/outline-sidebar/OutlineSidebarContext.tsx @@ -55,12 +55,6 @@ export const OutlineSidebarProvider = ({ children }: { children?: React.ReactNod const [selectedContainerId, setSelectedContainerId] = useState(); - const openContainerInfoSidebar = useCallback((containerId: string) => { - if (isOutlineNewDesignEnabled()) { - setSelectedContainerId(containerId); - } - }, [setSelectedContainerId]); - /** * Stops current add content flow. * This will cause the sidebar to switch back to its normal state and clear out any placeholder containers. @@ -76,6 +70,13 @@ export const OutlineSidebarProvider = ({ children }: { children?: React.ReactNod open(); }, [open, setCurrentFlow]); + const openContainerInfoSidebar = useCallback((containerId: string) => { + if (isOutlineNewDesignEnabled()) { + setSelectedContainerId(containerId); + setCurrentPageKey('info'); + } + }, [setSelectedContainerId]); + /** * Starts add content flow. * The sidebar enters an add content flow which allows user to add content in a specific container. @@ -90,6 +91,7 @@ export const OutlineSidebarProvider = ({ children }: { children?: React.ReactNod const handleEsc = (event: KeyboardEvent) => { if (event.key === 'Escape') { stopCurrentFlow(); + setSelectedContainerId(undefined); } }; window.addEventListener('keydown', handleEsc); diff --git a/src/course-outline/outline-sidebar/PublishButon.tsx b/src/course-outline/outline-sidebar/PublishButon.tsx new file mode 100644 index 0000000000..5e4aaf612a --- /dev/null +++ b/src/course-outline/outline-sidebar/PublishButon.tsx @@ -0,0 +1,25 @@ +import { FormattedMessage } from "@edx/frontend-platform/i18n" +import { Button } from "@openedx/paragon" +import messages from './messages'; + +interface Props { + onClick: () => void; +} + +export const PublishButon = ({ onClick }: Props) => { + return ( + + ) +} + diff --git a/src/course-outline/outline-sidebar/SectionnfoSidebar.tsx b/src/course-outline/outline-sidebar/SectionnfoSidebar.tsx new file mode 100644 index 0000000000..de081db89f --- /dev/null +++ b/src/course-outline/outline-sidebar/SectionnfoSidebar.tsx @@ -0,0 +1,78 @@ +import { useIntl } from '@edx/frontend-platform/i18n'; +import { useToggle } from '@openedx/paragon'; +import { SchoolOutline, Tag } from '@openedx/paragon/icons'; + +import { ContentTagsDrawerSheet, ContentTagsSnippet } from '@src/content-tags-drawer'; +import { ComponentCountSnippet } from '@src/generic/block-type-utils'; +import { useGetBlockTypes } from '@src/search-manager'; + +import { SidebarContent, SidebarSection, SidebarTitle } from '@src/generic/sidebar'; + +import messages from './messages'; +import { useCourseItemData } from '@src/course-outline/data/apiHooks'; +import Loading from '@src/generic/Loading'; +import { LibraryReferenceCard } from './LibraryReferenceCard'; +import { PublishButon } from './PublishButon'; + +interface Props { + sectionId: string; +} + +export const SectionInfoSidebar = ({ sectionId }: Props) => { + const intl = useIntl(); + const { data: componentData } = useGetBlockTypes( + [`breadcrumbs.usage_key = "${sectionId}"`], + ); + + const [isManageTagsDrawerOpen, openManageTagsDrawer, closeManageTagsDrawer] = useToggle(false); + + return ( + <> + + + + {componentData && } + + + + + + + + ); +}; + +export const SectionSidebar = ({ sectionId }: Props) => { + const { data: sectionData, isLoading } = useCourseItemData(sectionId); + + if (isLoading) { + return ; + } + + return ( + <> + + {}} /> + + + ); +} diff --git a/src/course-outline/outline-sidebar/constants.ts b/src/course-outline/outline-sidebar/constants.ts new file mode 100644 index 0000000000..39c084b777 --- /dev/null +++ b/src/course-outline/outline-sidebar/constants.ts @@ -0,0 +1,28 @@ +import { HelpOutline, Info, Plus } from '@openedx/paragon/icons'; +import type { SidebarPage } from '@src/generic/sidebar'; +import OutlineHelpSidebar from './OutlineHelpSidebar'; +import { InfoSidebar } from './InfoSidebar'; +import messages from './messages'; +import { AddSidebar } from './AddSidebar'; +import type { OutlineSidebarPageKeys } from './OutlineSidebarContext'; + +export type OutlineSidebarPages = Record; + +export const OUTLINE_SIDEBAR_PAGES: OutlineSidebarPages = { + info: { + component: InfoSidebar, + icon: Info, + title: messages.sidebarButtonInfo, + }, + help: { + component: OutlineHelpSidebar, + icon: HelpOutline, + title: messages.sidebarButtonHelp, + }, + add: { + component: AddSidebar, + icon: Plus, + title: messages.sidebarButtonAdd, + hideFromActionMenu: true, + }, +}; diff --git a/src/course-outline/outline-sidebar/messages.ts b/src/course-outline/outline-sidebar/messages.ts index dd98edbe9e..549d592402 100644 --- a/src/course-outline/outline-sidebar/messages.ts +++ b/src/course-outline/outline-sidebar/messages.ts @@ -125,6 +125,21 @@ const messages = defineMessages({ defaultMessage: 'Adding unit to {name}', description: 'Tab title for adding existing library unit to a specific parent in outline using sidebar', }, + sectionContentSummaryText: { + id: 'course-authoring.course-outline.sidebar.section.content-summary-text', + defaultMessage: 'Section Content Summary', + description: 'Title of the summary section in the section info sidebar', + }, + publishContainerButton: { + id: 'course-authoring.course-outline.sidebar.generic.publish.button', + defaultMessage: 'Publish Changes', + description: 'Publish button text', + }, + draftText: { + id: 'course-authoring.course-outline.sidebar.generic.draft.button', + defaultMessage: '(Draft)', + description: 'Draft text in publish button', + } }); export default messages; diff --git a/src/library-authoring/generic/publish-status-buttons/index.scss b/src/library-authoring/generic/publish-status-buttons/index.scss index ddeddbe72a..0cd5463541 100644 --- a/src/library-authoring/generic/publish-status-buttons/index.scss +++ b/src/library-authoring/generic/publish-status-buttons/index.scss @@ -1,6 +1,6 @@ .status-button { border: 1px solid; - border-left: 4px solid; + border-left: 6px solid; text-align: center; white-space: pre-wrap; diff --git a/src/library-authoring/generic/status-widget/StatusWidget.scss b/src/library-authoring/generic/status-widget/StatusWidget.scss index fcbe24c527..0095326c14 100644 --- a/src/library-authoring/generic/status-widget/StatusWidget.scss +++ b/src/library-authoring/generic/status-widget/StatusWidget.scss @@ -1,6 +1,6 @@ %draft-status { background-color: #FDF3E9; - border-color: #F4B57B !important; + border-color: #B4610E !important; color: #00262B; } From c37f86f0b80e15f6ba34ae4e53a2a50d75e87d07 Mon Sep 17 00:00:00 2001 From: Navin Karkera Date: Fri, 16 Jan 2026 15:21:44 +0530 Subject: [PATCH 02/60] feat: library reference card --- .../outline-sidebar/InfoSidebar.tsx | 5 +- .../outline-sidebar/LibraryReferenceCard.tsx | 120 +++++++++++++++++- ...nnfoSidebar.tsx => SectionInfoSidebar.tsx} | 25 +++- .../outline-sidebar/SubsectionInfoSidebar.tsx | 95 ++++++++++++++ .../outline-sidebar/messages.ts | 72 ++++++++++- src/data/types.ts | 2 +- 6 files changed, 309 insertions(+), 10 deletions(-) rename src/course-outline/outline-sidebar/{SectionnfoSidebar.tsx => SectionInfoSidebar.tsx} (73%) create mode 100644 src/course-outline/outline-sidebar/SubsectionInfoSidebar.tsx diff --git a/src/course-outline/outline-sidebar/InfoSidebar.tsx b/src/course-outline/outline-sidebar/InfoSidebar.tsx index 65c82b0191..bf24b7e093 100644 --- a/src/course-outline/outline-sidebar/InfoSidebar.tsx +++ b/src/course-outline/outline-sidebar/InfoSidebar.tsx @@ -1,7 +1,8 @@ import { useOutlineSidebarContext } from "./OutlineSidebarContext"; import { CourseInfoSidebar } from "./CourseInfoSidebar" import { ContainerType, getBlockType } from "@src/generic/key-utils"; -import { SectionSidebar } from "./SectionnfoSidebar"; +import { SectionSidebar } from "./SectionInfoSidebar"; +import { SubsectionSidebar } from "@src/course-outline/outline-sidebar/SubsectionInfoSidebar"; export const InfoSidebar = () => { const { selectedContainerId } = useOutlineSidebarContext(); @@ -18,7 +19,7 @@ export const InfoSidebar = () => { return case ContainerType.Sequential: case ContainerType.Subsection: - return
Subsection sidebar
; + return case ContainerType.Vertical: case ContainerType.Unit: return
Unit sidebar
; diff --git a/src/course-outline/outline-sidebar/LibraryReferenceCard.tsx b/src/course-outline/outline-sidebar/LibraryReferenceCard.tsx index 22578cd6b2..48acff0a7c 100644 --- a/src/course-outline/outline-sidebar/LibraryReferenceCard.tsx +++ b/src/course-outline/outline-sidebar/LibraryReferenceCard.tsx @@ -1,6 +1,122 @@ -export const LibraryReferenceCard = () => { +import { FormattedMessage } from "@edx/frontend-platform/i18n"; +import { Button, Card, Icon, Stack } from "@openedx/paragon"; +import { Cached, LinkOff, Newsstand } from "@openedx/paragon/icons"; +import { useCourseItemData } from "@src/course-outline/data/apiHooks"; +import { useOutlineSidebarContext } from "@src/course-outline/outline-sidebar/OutlineSidebarContext"; +import { UpstreamInfo } from "@src/data/types"; +import { getBlockType, normalizeContainerType } from "@src/generic/key-utils"; +import messages from './messages'; + +interface SubProps { + upstreamInfo: UpstreamInfo; + displayName: string; +} + +const HasTopParentTextAndButton = ({ upstreamInfo, displayName }: SubProps) => { + const { openContainerInfoSidebar } = useOutlineSidebarContext(); + if (!upstreamInfo.topLevelParentKey) { + return null; + } + + const messageValues = { + parentType: normalizeContainerType(getBlockType(upstreamInfo.topLevelParentKey)), + name: displayName, + } + + if (upstreamInfo.errorMessage) { + return ( + + + + + ); + } + + if (upstreamInfo.readyToSync) { + return ( + + + + + ); + } + + return ( + + + + + ); +} + +const TopLevelTextAndButton = ({ upstreamInfo, displayName }: SubProps) => { + const messageValues = { + name: displayName, + } + + if (upstreamInfo.errorMessage) { + return ( + + + + + ); + } + + if (upstreamInfo.readyToSync) { + return ( + + + + + ); + } + + if (upstreamInfo.downstreamCustomized.length > 0) { + return ( + + ); + } + + return null; +} + +interface Props { + sectionId?: string; +} + +export const LibraryReferenceCard = ({ sectionId }: Props) => { + const { data: sectionData, isLoading } = useCourseItemData(sectionId); + if (!sectionData?.upstreamInfo?.upstreamRef) { + return null; + } + return ( -
LibraryReferenceCard
+ + + + + +

+
+ + +
+
+
) }; diff --git a/src/course-outline/outline-sidebar/SectionnfoSidebar.tsx b/src/course-outline/outline-sidebar/SectionInfoSidebar.tsx similarity index 73% rename from src/course-outline/outline-sidebar/SectionnfoSidebar.tsx rename to src/course-outline/outline-sidebar/SectionInfoSidebar.tsx index de081db89f..f42376c315 100644 --- a/src/course-outline/outline-sidebar/SectionnfoSidebar.tsx +++ b/src/course-outline/outline-sidebar/SectionInfoSidebar.tsx @@ -1,5 +1,6 @@ +import { useState } from 'react'; import { useIntl } from '@edx/frontend-platform/i18n'; -import { useToggle } from '@openedx/paragon'; +import { Tab, Tabs, useToggle } from '@openedx/paragon'; import { SchoolOutline, Tag } from '@openedx/paragon/icons'; import { ContentTagsDrawerSheet, ContentTagsSnippet } from '@src/content-tags-drawer'; @@ -18,7 +19,7 @@ interface Props { sectionId: string; } -export const SectionInfoSidebar = ({ sectionId }: Props) => { +const SectionInfoSidebar = ({ sectionId }: Props) => { const intl = useIntl(); const { data: componentData } = useGetBlockTypes( [`breadcrumbs.usage_key = "${sectionId}"`], @@ -29,7 +30,7 @@ export const SectionInfoSidebar = ({ sectionId }: Props) => { return ( <> - + { }; export const SectionSidebar = ({ sectionId }: Props) => { + const intl = useIntl(); + const [tab, setTab] = useState<'info' | 'settings'>('info'); const { data: sectionData, isLoading } = useCourseItemData(sectionId); if (isLoading) { @@ -72,7 +75,21 @@ export const SectionSidebar = ({ sectionId }: Props) => { icon={SchoolOutline} /> {}} /> - + + + + + +
Settings
+
+
); } diff --git a/src/course-outline/outline-sidebar/SubsectionInfoSidebar.tsx b/src/course-outline/outline-sidebar/SubsectionInfoSidebar.tsx new file mode 100644 index 0000000000..bb11e01317 --- /dev/null +++ b/src/course-outline/outline-sidebar/SubsectionInfoSidebar.tsx @@ -0,0 +1,95 @@ +import { useState } from 'react'; +import { useIntl } from '@edx/frontend-platform/i18n'; +import { Tab, Tabs, useToggle } from '@openedx/paragon'; +import { SchoolOutline, Tag } from '@openedx/paragon/icons'; + +import { ContentTagsDrawerSheet, ContentTagsSnippet } from '@src/content-tags-drawer'; +import { ComponentCountSnippet } from '@src/generic/block-type-utils'; +import { useGetBlockTypes } from '@src/search-manager'; + +import { SidebarContent, SidebarSection, SidebarTitle } from '@src/generic/sidebar'; + +import messages from './messages'; +import { useCourseItemData } from '@src/course-outline/data/apiHooks'; +import Loading from '@src/generic/Loading'; +import { LibraryReferenceCard } from './LibraryReferenceCard'; +import { PublishButon } from './PublishButon'; + +interface Props { + subsectionId: string; +} + +const SubsectionInfoSidebar = ({ subsectionId }: Props) => { + const intl = useIntl(); + const { data: componentData } = useGetBlockTypes( + [`breadcrumbs.usage_key = "${subsectionId}"`], + ); + + const [isManageTagsDrawerOpen, openManageTagsDrawer, closeManageTagsDrawer] = useToggle(false); + + return ( + <> + + + + {componentData && } + + + + + + + + ); +}; + +export const SubsectionSidebar = ({ subsectionId }: Props) => { + const intl = useIntl(); + const [tab, setTab] = useState<'info' | 'settings'>('info'); + const { data: sectionData, isLoading } = useCourseItemData(subsectionId); + + if (isLoading) { + return ; + } + + return ( + <> + + {}} /> + + + + + +
Settings
+
+
+ + ); +} diff --git a/src/course-outline/outline-sidebar/messages.ts b/src/course-outline/outline-sidebar/messages.ts index 549d592402..09d3cacfab 100644 --- a/src/course-outline/outline-sidebar/messages.ts +++ b/src/course-outline/outline-sidebar/messages.ts @@ -139,7 +139,77 @@ const messages = defineMessages({ id: 'course-authoring.course-outline.sidebar.generic.draft.button', defaultMessage: '(Draft)', description: 'Draft text in publish button', - } + }, + infoTabText: { + id: 'course-authoring.course-outline.sidebar.generic.info.tab.text', + defaultMessage: 'Details', + description: 'Information tab title in section sidebar', + }, + settingsTabText: { + id: 'course-authoring.course-outline.sidebar.generic.info.settings.text', + defaultMessage: 'Settings', + description: 'Settings tab title in section sidebar', + }, + libraryReferenceCardText: { + id: 'course-authoring.course-outline.sidebar.library.reference.card.text', + defaultMessage: 'Library Reference', + description: 'Library reference card text in sidebar', + }, + hasTopParentText: { + id: 'course-authoring.course-outline.sidebar.library.reference.card.has-top-parent-text', + defaultMessage: '{name} was reused as part of a {parentType}.', + description: 'Text displayed in sidebar library reference card when a block was reused as part of a parent block', + }, + hasTopParentBtn: { + id: 'course-authoring.course-outline.sidebar.library.reference.card.has-top-parent-btn', + defaultMessage: 'View {parentType}', + description: 'Text displayed in sidebar library reference card button when a block was reused as part of a parent block', + }, + hasTopParentReadyToSyncText: { + id: 'course-authoring.course-outline.sidebar.library.reference.card.has-top-parent-sync-text', + defaultMessage: '{name} was reused as part of a {parentType} which has updates available.', + description: 'Text displayed in sidebar library reference card when a block has updates available as it was reused as part of a parent block', + }, + hasTopParentReadyToSyncBtn: { + id: 'course-authoring.course-outline.sidebar.library.reference.card.has-top-parent-sync-btn', + defaultMessage: 'Review Updates', + description: 'Text displayed in sidebar library reference card button when a block has updates available as it was reused as part of a parent block', + }, + hasTopParentBrokenLinkText: { + id: 'course-authoring.course-outline.sidebar.library.reference.card.has-top-parent-broken-link-text', + defaultMessage: '{name} was reused as part of a {parentType} which has a broken link. To recieve library updates to this component, unlink the broken link.', + description: 'Text displayed in sidebar library reference card when a block was reused as part of a parent block which has a broken link.', + }, + hasTopParentBrokenLinkBtn: { + id: 'course-authoring.course-outline.sidebar.library.reference.card.has-top-parent-broken-link-btn', + defaultMessage: 'Unlink {parentType}', + description: 'Text displayed in sidebar library reference card button when a block was reused as part of a parent block which has a broken link.', + }, + topParentBrokenLinkText: { + id: 'course-authoring.course-outline.sidebar.library.reference.card.top-parent-broken-link-text', + defaultMessage: 'The link between {name} and the library version has been broken. To edit or make changes, unlink component.', + description: 'Text displayed in sidebar library reference card when a block has a broken link.', + }, + topParentBrokenLinkBtn: { + id: 'course-authoring.course-outline.sidebar.library.reference.card.top-parent-broken-link-btn', + defaultMessage: 'Unlink from library', + description: 'Text displayed in sidebar library reference card button when a block has a broken link.', + }, + topParentModifiedText: { + id: 'course-authoring.course-outline.sidebar.library.reference.card.top-parent-modified-text', + defaultMessage: '{name} has been modified in this course.', + description: 'Text displayed in sidebar library reference card when it is modified in course.', + }, + topParentReaadyToSyncText: { + id: 'course-authoring.course-outline.sidebar.library.reference.card.top-parent-ready-to-sync-text', + defaultMessage: '{name} has available updates', + description: 'Text displayed in sidebar library reference card when it is has updates available.', + }, + topParentReaadyToSyncBtn: { + id: 'course-authoring.course-outline.sidebar.library.reference.card.top-parent-ready-to-sync-btn', + defaultMessage: 'Review Updates', + description: 'Text displayed in sidebar library reference card button when it is has updates available.', + }, }); export default messages; diff --git a/src/data/types.ts b/src/data/types.ts index 246f8c8c9e..b08656dd25 100644 --- a/src/data/types.ts +++ b/src/data/types.ts @@ -65,7 +65,7 @@ export interface UpstreamInfo { versionDeclined: number | null, errorMessage: string | null, downstreamCustomized: string[], - hasTopLevelParent?: boolean, + topLevelParentKey?: string, readyToSyncChildren?: UpstreamChildrenInfo[], isReadyToSyncIndividually?: boolean, } From 1740fce0adc8d0d651c49fa96daaff33fc2a4b62 Mon Sep 17 00:00:00 2001 From: Navin Karkera Date: Fri, 16 Jan 2026 19:47:41 +0530 Subject: [PATCH 03/60] refactor: unlink modal state and handlers --- src/CourseAuthoringContext.tsx | 44 ++++++++++++++++++- src/course-outline/CourseOutline.tsx | 10 ++--- src/course-outline/hooks.jsx | 19 -------- .../outline-sidebar/LibraryReferenceCard.tsx | 12 ++--- .../outline-sidebar/OutlineSidebarContext.tsx | 10 ++++- .../section-card/SectionCard.tsx | 6 +-- .../subsection-card/SubsectionCard.tsx | 6 +-- src/course-outline/unit-card/UnitCard.tsx | 6 +-- src/hooks.ts | 16 +++++++ 9 files changed, 83 insertions(+), 46 deletions(-) diff --git a/src/CourseAuthoringContext.tsx b/src/CourseAuthoringContext.tsx index e51484da37..05ce784ff6 100644 --- a/src/CourseAuthoringContext.tsx +++ b/src/CourseAuthoringContext.tsx @@ -1,5 +1,5 @@ import { getConfig } from '@edx/frontend-platform'; -import { createContext, useContext, useMemo } from 'react'; +import { createContext, useCallback, useContext, useMemo } from 'react'; import { getAuthenticatedUser } from '@edx/frontend-platform/auth'; import { useCreateCourseBlock } from '@src/course-outline/data/apiHooks'; import { getCourseItem } from '@src/course-outline/data/api'; @@ -10,6 +10,15 @@ import { getOutlineIndexData } from '@src/course-outline/data/selectors'; import { RequestStatus, RequestStatusType } from './data/constants'; import { useCourseDetails, useWaffleFlags } from './data/apiHooks'; import { CourseDetailsData } from './data/api'; +import { useToggleWithValue } from '@src/hooks'; +import { XBlock } from '@src/data/types'; +import { useUnlinkDownstream } from '@src/generic/unlink-modal'; +import { fetchCourseSectionQuery } from '@src/course-outline/data/thunk'; + +type UnlinkModalState = { + value: XBlock; + sectionId: string; +} export type CourseAuthoringContextData = { /** The ID of the current course */ @@ -23,6 +32,11 @@ export type CourseAuthoringContextData = { handleAddUnit: ReturnType; openUnitPage: (locator: string) => void; getUnitUrl: (locator: string) => string; + isUnlinkModalOpen: boolean; + currentUnlinkModalData?: UnlinkModalState; + openUnlinkModal: (value: UnlinkModalState) => void; + closeUnlinkModal: () => void; + handleUnlinkItemSubmit: () => Promise; }; /** @@ -50,6 +64,7 @@ export const CourseAuthoringProvider = ({ const canChangeProviders = getAuthenticatedUser().administrator || new Date(courseDetails?.start ?? 0) > new Date(); const { courseStructure } = useSelector(getOutlineIndexData); const { id: courseUsageKey } = courseStructure || {}; + const [isUnlinkModalOpen, currentUnlinkModalData, openUnlinkModal, closeUnlinkModal] = useToggleWithValue(); const getUnitUrl = (locator: string) => { if (getConfig().ENABLE_UNIT_PAGE === 'true' && waffleFlags.useNewUnitPage) { @@ -59,6 +74,23 @@ export const CourseAuthoringProvider = ({ return `${getConfig().STUDIO_BASE_URL}/container/${locator}`; }; + const { mutateAsync: unlinkDownstream } = useUnlinkDownstream(); + + /** Handle the submit of the item unlinking XBlock from library counterpart. */ + const handleUnlinkItemSubmit = useCallback(async () => { + // istanbul ignore if: this should never happen + if (!currentUnlinkModalData) { + return; + } + + await unlinkDownstream(currentUnlinkModalData.value.id, { + onSuccess: () => { + dispatch(fetchCourseSectionQuery([currentUnlinkModalData.sectionId])) + closeUnlinkModal(); + }, + }); + }, [currentUnlinkModalData, unlinkDownstream, closeUnlinkModal, fetchCourseSectionQuery]); + /** * Open the unit page for a given locator. */ @@ -113,6 +145,11 @@ export const CourseAuthoringProvider = ({ handleAddUnit, getUnitUrl, openUnitPage, + isUnlinkModalOpen, + openUnlinkModal, + closeUnlinkModal, + currentUnlinkModalData, + handleUnlinkItemSubmit, }), [ courseId, courseUsageKey, @@ -124,6 +161,11 @@ export const CourseAuthoringProvider = ({ handleAddUnit, getUnitUrl, openUnitPage, + isUnlinkModalOpen, + openUnlinkModal, + closeUnlinkModal, + currentUnlinkModalData, + handleUnlinkItemSubmit, ]); return ( diff --git a/src/course-outline/CourseOutline.tsx b/src/course-outline/CourseOutline.tsx index 68f4406378..8af2d7ff49 100644 --- a/src/course-outline/CourseOutline.tsx +++ b/src/course-outline/CourseOutline.tsx @@ -75,6 +75,9 @@ const CourseOutline = () => { handleAddSubsection, handleAddUnit, handleAddSection, + isUnlinkModalOpen, + closeUnlinkModal, + handleUnlinkItemSubmit, } = useCourseAuthoringContext(); const { @@ -96,16 +99,13 @@ const CourseOutline = () => { isPublishModalOpen, isConfigureModalOpen, isDeleteModalOpen, - isUnlinkModalOpen, closeHighlightsModal, closePublishModal, handleConfigureModalClose, closeDeleteModal, - closeUnlinkModal, openPublishModal, openConfigureModal, openDeleteModal, - openUnlinkModal, headerNavigationsActions, openEnableHighlightsModal, closeEnableHighlightsModal, @@ -117,7 +117,6 @@ const CourseOutline = () => { handlePublishItemSubmit, handleEditSubmit, handleDeleteItemSubmit, - handleUnlinkItemSubmit, handleDuplicateSectionSubmit, handleDuplicateSubsectionSubmit, handleDuplicateUnitSubmit, @@ -390,7 +389,6 @@ const CourseOutline = () => { onOpenPublishModal={openPublishModal} onOpenConfigureModal={openConfigureModal} onOpenDeleteModal={openDeleteModal} - onOpenUnlinkModal={openUnlinkModal} onEditSectionSubmit={handleEditSubmit} onDuplicateSubmit={handleDuplicateSectionSubmit} isSectionsExpanded={isSectionsExpanded} @@ -420,7 +418,6 @@ const CourseOutline = () => { savingStatus={savingStatus} onOpenPublishModal={openPublishModal} onOpenDeleteModal={openDeleteModal} - onOpenUnlinkModal={openUnlinkModal} onEditSubmit={handleEditSubmit} onDuplicateSubmit={handleDuplicateSubsectionSubmit} onOpenConfigureModal={openConfigureModal} @@ -454,7 +451,6 @@ const CourseOutline = () => { onOpenPublishModal={openPublishModal} onOpenConfigureModal={openConfigureModal} onOpenDeleteModal={openDeleteModal} - onOpenUnlinkModal={openUnlinkModal} onEditSubmit={handleEditSubmit} onDuplicateSubmit={handleDuplicateUnitSubmit} onOrderChange={updateUnitOrderByIndex} diff --git a/src/course-outline/hooks.jsx b/src/course-outline/hooks.jsx index 43ce130d58..fb2507208e 100644 --- a/src/course-outline/hooks.jsx +++ b/src/course-outline/hooks.jsx @@ -7,7 +7,6 @@ import { useQueryClient } from '@tanstack/react-query'; import moment from 'moment'; import { getSavingStatus as getGenericSavingStatus } from '@src/generic/data/selectors'; import { RequestStatus } from '@src/data/constants'; -import { useUnlinkDownstream } from '@src/generic/unlink-modal'; import { useCourseAuthoringContext } from '@src/CourseAuthoringContext'; import { ContainerType } from '@src/generic/key-utils'; @@ -99,7 +98,6 @@ const useCourseOutline = ({ courseId }) => { const [isPublishModalOpen, openPublishModal, closePublishModal] = useToggle(false); const [isConfigureModalOpen, openConfigureModal, closeConfigureModal] = useToggle(false); const [isDeleteModalOpen, openDeleteModal, closeDeleteModal] = useToggle(false); - const [isUnlinkModalOpen, openUnlinkModal, closeUnlinkModal] = useToggle(false); const isSavingStatusFailed = savingStatus === RequestStatus.FAILED || genericSavingStatus === RequestStatus.FAILED; @@ -212,19 +210,6 @@ const useCourseOutline = ({ courseId }) => { closeDeleteModal(); }; - const { mutateAsync: unlinkDownstream } = useUnlinkDownstream(); - - const handleUnlinkItemSubmit = async () => { - // istanbul ignore if: this should never happen - if (!currentItem.id) { - return; - } - - await unlinkDownstream(currentItem.id); - dispatch(fetchCourseOutlineIndexQuery(courseId)); - closeUnlinkModal(); - }; - const handleDuplicateSectionSubmit = () => { dispatch(duplicateSectionQuery(currentSection.id, courseStructure.id)); }; @@ -339,11 +324,7 @@ const useCourseOutline = ({ courseId }) => { isDeleteModalOpen, closeDeleteModal, openDeleteModal, - isUnlinkModalOpen, - closeUnlinkModal, - openUnlinkModal, handleDeleteItemSubmit, - handleUnlinkItemSubmit, handleDuplicateSectionSubmit, handleDuplicateSubsectionSubmit, handleDuplicateUnitSubmit, diff --git a/src/course-outline/outline-sidebar/LibraryReferenceCard.tsx b/src/course-outline/outline-sidebar/LibraryReferenceCard.tsx index 48acff0a7c..3b688299e5 100644 --- a/src/course-outline/outline-sidebar/LibraryReferenceCard.tsx +++ b/src/course-outline/outline-sidebar/LibraryReferenceCard.tsx @@ -95,12 +95,12 @@ const TopLevelTextAndButton = ({ upstreamInfo, displayName }: SubProps) => { } interface Props { - sectionId?: string; + itemId?: string; } -export const LibraryReferenceCard = ({ sectionId }: Props) => { - const { data: sectionData, isLoading } = useCourseItemData(sectionId); - if (!sectionData?.upstreamInfo?.upstreamRef) { +export const LibraryReferenceCard = ({ itemId }: Props) => { + const { data: itemData, isLoading } = useCourseItemData(itemId); + if (!itemData?.upstreamInfo?.upstreamRef) { return null; } @@ -112,8 +112,8 @@ export const LibraryReferenceCard = ({ sectionId }: Props) => {

- - + + diff --git a/src/course-outline/outline-sidebar/OutlineSidebarContext.tsx b/src/course-outline/outline-sidebar/OutlineSidebarContext.tsx index 171321520e..bd007c5e7c 100644 --- a/src/course-outline/outline-sidebar/OutlineSidebarContext.tsx +++ b/src/course-outline/outline-sidebar/OutlineSidebarContext.tsx @@ -8,7 +8,7 @@ import { } from 'react'; import { useToggle } from '@openedx/paragon'; -import { useStateWithUrlSearchParam } from '@src/hooks'; +import { useEscapeClick, useStateWithUrlSearchParam } from '@src/hooks'; import { isOutlineNewDesignEnabled } from '../utils'; export type OutlineSidebarPageKeys = 'help' | 'info' | 'add' | 'align'; @@ -87,6 +87,14 @@ export const OutlineSidebarProvider = ({ children }: { children?: React.ReactNod setCurrentFlow(flow); }, [setCurrentFlow, setCurrentPageKey]); + useEscapeClick({ + onEscape: () => { + stopCurrentFlow(); + setSelectedContainerId(undefined); + }, + dependency: [stopCurrentFlow, selectedContainerId], + }); + useEffect(() => { const handleEsc = (event: KeyboardEvent) => { if (event.key === 'Escape') { diff --git a/src/course-outline/section-card/SectionCard.tsx b/src/course-outline/section-card/SectionCard.tsx index 70261be217..84372568de 100644 --- a/src/course-outline/section-card/SectionCard.tsx +++ b/src/course-outline/section-card/SectionCard.tsx @@ -39,7 +39,6 @@ interface SectionCardProps { onEditSectionSubmit: (itemId: string, sectionId: string, displayName: string) => void, savingStatus?: RequestStatusType, onOpenDeleteModal: () => void, - onOpenUnlinkModal: () => void, onDuplicateSubmit: () => void, isSectionsExpanded: boolean, index: number, @@ -61,7 +60,6 @@ const SectionCard = ({ onEditSectionSubmit, savingStatus, onOpenDeleteModal, - onOpenUnlinkModal, onDuplicateSubmit, isSectionsExpanded, onOrderChange, @@ -74,7 +72,7 @@ const SectionCard = ({ const [searchParams] = useSearchParams(); const locatorId = searchParams.get('show'); const isScrolledToElement = locatorId === section.id; - const { courseId } = useCourseAuthoringContext(); + const { courseId, openUnlinkModal } = useCourseAuthoringContext(); const queryClient = useQueryClient(); // Expand the section if a search result should be shown/scrolled to @@ -286,7 +284,7 @@ const SectionCard = ({ onClickConfigure={onOpenConfigureModal} onClickEdit={openForm} onClickDelete={onOpenDeleteModal} - onClickUnlink={onOpenUnlinkModal} + onClickUnlink={() => openUnlinkModal({ value: section, sectionId: section.id })} onClickMoveUp={handleSectionMoveUp} onClickMoveDown={handleSectionMoveDown} onClickSync={openSyncModal} diff --git a/src/course-outline/subsection-card/SubsectionCard.tsx b/src/course-outline/subsection-card/SubsectionCard.tsx index 4031b198c6..6a8da4b8a2 100644 --- a/src/course-outline/subsection-card/SubsectionCard.tsx +++ b/src/course-outline/subsection-card/SubsectionCard.tsx @@ -41,7 +41,6 @@ interface SubsectionCardProps { onEditSubmit: (itemId: string, sectionId: string, displayName: string) => void, savingStatus?: RequestStatusType, onOpenDeleteModal: () => void, - onOpenUnlinkModal: () => void, onDuplicateSubmit: () => void, index: number, getPossibleMoves: (index: number, step: number) => void, @@ -64,7 +63,6 @@ const SubsectionCard = ({ onEditSubmit, savingStatus, onOpenDeleteModal, - onOpenUnlinkModal, onDuplicateSubmit, onOrderChange, onOpenConfigureModal, @@ -83,7 +81,7 @@ const SubsectionCard = ({ const [isSyncModalOpen, openSyncModal, closeSyncModal] = useToggle(false); const namePrefix = 'subsection'; const { sharedClipboardData, showPasteUnit } = useClipboard(); - const { courseId } = useCourseAuthoringContext(); + const { courseId, openUnlinkModal } = useCourseAuthoringContext(); const queryClient = useQueryClient(); const { @@ -289,7 +287,7 @@ const SubsectionCard = ({ onClickPublish={onOpenPublishModal} onClickEdit={openForm} onClickDelete={onOpenDeleteModal} - onClickUnlink={onOpenUnlinkModal} + onClickUnlink={() => openUnlinkModal({ value: subsection, sectionId: section.id })} onClickMoveUp={handleSubsectionMoveUp} onClickMoveDown={handleSubsectionMoveDown} onClickConfigure={onOpenConfigureModal} diff --git a/src/course-outline/unit-card/UnitCard.tsx b/src/course-outline/unit-card/UnitCard.tsx index 922a6ab38d..80d6ba8a29 100644 --- a/src/course-outline/unit-card/UnitCard.tsx +++ b/src/course-outline/unit-card/UnitCard.tsx @@ -37,7 +37,6 @@ interface UnitCardProps { onEditSubmit: (itemId: string, sectionId: string, displayName: string) => void, savingStatus?: RequestStatusType; onOpenDeleteModal: () => void; - onOpenUnlinkModal: () => void; onDuplicateSubmit: () => void; index: number; getPossibleMoves: (index: number, step: number) => void, @@ -63,7 +62,6 @@ const UnitCard = ({ onEditSubmit, savingStatus, onOpenDeleteModal, - onOpenUnlinkModal, onDuplicateSubmit, onOrderChange, discussionsSettings, @@ -79,7 +77,7 @@ const UnitCard = ({ const namePrefix = 'unit'; const { copyToClipboard } = useClipboard(); - const { courseId, getUnitUrl } = useCourseAuthoringContext(); + const { courseId, getUnitUrl, openUnlinkModal } = useCourseAuthoringContext(); const queryClient = useQueryClient(); const { @@ -261,7 +259,7 @@ const UnitCard = ({ onClickConfigure={onOpenConfigureModal} onClickEdit={openForm} onClickDelete={onOpenDeleteModal} - onClickUnlink={onOpenUnlinkModal} + onClickUnlink={() => openUnlinkModal({ value: unit, sectionId: section.id })} onClickMoveUp={handleUnitMoveUp} onClickMoveDown={handleUnitMoveDown} onClickSync={openSyncModal} diff --git a/src/hooks.ts b/src/hooks.ts index b1dacca6ed..1ee155ec81 100644 --- a/src/hooks.ts +++ b/src/hooks.ts @@ -5,6 +5,7 @@ import { useEffect, useRef, useState, + useMemo, } from 'react'; import { history } from '@edx/frontend-platform'; import { useLocation, useSearchParams } from 'react-router-dom'; @@ -213,3 +214,18 @@ export function useStickyState( return [value, setValue]; } + + +export function useToggleWithValue(defaultValue?: T): [ + isDefined: boolean, value: T | undefined, define: ((val: T) => void), undefine: () => void, +] { + const [value, setValue] = useState(defaultValue); + const define = useCallback((val: T) => { + setValue(val); + }, []); + const undefine = useCallback(() => { + setValue(undefined); + }, []); + const isDefined = useMemo(() => value !== undefined, [value]); + return [isDefined, value, define, undefine]; +} From 18a45ab299f70530ac93fadb6a5be4918ca57ac5 Mon Sep 17 00:00:00 2001 From: Navin Karkera Date: Mon, 19 Jan 2026 11:17:32 +0530 Subject: [PATCH 04/60] refactor: unlink action --- src/course-outline/data/apiHooks.ts | 18 ++++- src/course-outline/hooks.jsx | 5 ++ .../outline-sidebar/LibraryReferenceCard.tsx | 80 ++++++++++++++----- .../outline-sidebar/OutlineSidebarContext.tsx | 33 ++++---- .../outline-sidebar/SectionInfoSidebar.tsx | 2 +- .../outline-sidebar/SubsectionInfoSidebar.tsx | 4 +- .../outline-sidebar/messages.ts | 5 ++ .../section-card/SectionCard.tsx | 2 +- .../subsection-card/SubsectionCard.tsx | 2 +- src/course-outline/unit-card/UnitCard.tsx | 2 +- src/generic/sidebar/SidebarContent.tsx | 2 +- src/generic/unlink-modal/data/apiHooks.ts | 4 + 12 files changed, 110 insertions(+), 49 deletions(-) diff --git a/src/course-outline/data/apiHooks.ts b/src/course-outline/data/apiHooks.ts index 9a67ecf32f..a739310437 100644 --- a/src/course-outline/data/apiHooks.ts +++ b/src/course-outline/data/apiHooks.ts @@ -1,3 +1,4 @@ +import { getCourseKey } from '@src/generic/key-utils'; import { skipToken, useMutation, useQuery } from '@tanstack/react-query'; import { createCourseXblock, getCourseDetails, getCourseItem } from './api'; @@ -6,10 +7,19 @@ export const courseOutlineQueryKeys = { /** * Base key for data specific to a course in outline */ - contentLibrary: (courseId?: string) => [...courseOutlineQueryKeys.all, courseId], - courseItemId: (itemId?: string) => [...courseOutlineQueryKeys.all, itemId], - courseDetails: (courseId?: string) => [...courseOutlineQueryKeys.all, courseId, 'details'], - legacyLibReadyToMigrateBlocks: (courseId: string) => [...courseOutlineQueryKeys.all, courseId, 'legacyLibReadyToMigrateBlocks'], + course: (courseId?: string) => [...courseOutlineQueryKeys.all, courseId], + courseItemId: (itemId?: string) => [ + ...courseOutlineQueryKeys.course(itemId ? getCourseKey(itemId): undefined), + itemId, + ], + courseDetails: (courseId?: string) => [ + ...courseOutlineQueryKeys.course(courseId), + 'details', + ], + legacyLibReadyToMigrateBlocks: (courseId: string) => [ + ...courseOutlineQueryKeys.course(courseId), + 'legacyLibReadyToMigrateBlocks', + ], legacyLibReadyToMigrateBlocksStatus: (courseId: string, taskId?: string) => [ ...courseOutlineQueryKeys.legacyLibReadyToMigrateBlocks(courseId), 'status', diff --git a/src/course-outline/hooks.jsx b/src/course-outline/hooks.jsx index fb2507208e..b82e68cb49 100644 --- a/src/course-outline/hooks.jsx +++ b/src/course-outline/hooks.jsx @@ -58,11 +58,13 @@ import { syncDiscussionsTopics, } from './data/thunk'; import { containerComparisonQueryKeys } from '../container-comparison/data/apiHooks'; +import { useOutlineSidebarContext } from '@src/course-outline/outline-sidebar/OutlineSidebarContext'; const useCourseOutline = ({ courseId }) => { const dispatch = useDispatch(); const queryClient = useQueryClient(); const { handleAddSection } = useCourseAuthoringContext(); + const { selectedContainerId, clearSelection } = useOutlineSidebarContext(); const { reindexLink, @@ -207,6 +209,9 @@ const useCourseOutline = ({ courseId }) => { default: return; } + if (selectedContainerId === currentItem.id) { + clearSelection(); + } closeDeleteModal(); }; diff --git a/src/course-outline/outline-sidebar/LibraryReferenceCard.tsx b/src/course-outline/outline-sidebar/LibraryReferenceCard.tsx index 3b688299e5..5fe2a0b28e 100644 --- a/src/course-outline/outline-sidebar/LibraryReferenceCard.tsx +++ b/src/course-outline/outline-sidebar/LibraryReferenceCard.tsx @@ -3,18 +3,31 @@ import { Button, Card, Icon, Stack } from "@openedx/paragon"; import { Cached, LinkOff, Newsstand } from "@openedx/paragon/icons"; import { useCourseItemData } from "@src/course-outline/data/apiHooks"; import { useOutlineSidebarContext } from "@src/course-outline/outline-sidebar/OutlineSidebarContext"; -import { UpstreamInfo } from "@src/data/types"; +import { useCourseAuthoringContext } from "@src/CourseAuthoringContext"; +import { XBlock } from "@src/data/types"; import { getBlockType, normalizeContainerType } from "@src/generic/key-utils"; import messages from './messages'; interface SubProps { - upstreamInfo: UpstreamInfo; + blockData: XBlock; displayName: string; } -const HasTopParentTextAndButton = ({ upstreamInfo, displayName }: SubProps) => { +const HasTopParentTextAndButton = ({ blockData, displayName }: SubProps) => { + const { upstreamInfo } = blockData; + const { selectedSectionId } = useOutlineSidebarContext(); const { openContainerInfoSidebar } = useOutlineSidebarContext(); - if (!upstreamInfo.topLevelParentKey) { + const { openUnlinkModal } = useCourseAuthoringContext(); + const { data: parentData, isPending } = useCourseItemData(upstreamInfo?.topLevelParentKey); + + const handleUnlinkClick = () => { + if (!selectedSectionId || !parentData) { + return; + } + openUnlinkModal({ value: parentData, sectionId: selectedSectionId }); + }; + + if (!upstreamInfo?.topLevelParentKey) { return null; } @@ -27,7 +40,12 @@ const HasTopParentTextAndButton = ({ upstreamInfo, displayName }: SubProps) => { return ( - @@ -58,23 +76,33 @@ const HasTopParentTextAndButton = ({ upstreamInfo, displayName }: SubProps) => { ); } -const TopLevelTextAndButton = ({ upstreamInfo, displayName }: SubProps) => { +const TopLevelTextAndButton = ({ blockData, displayName }: SubProps) => { + const { upstreamInfo } = blockData; + const { selectedSectionId } = useOutlineSidebarContext(); + const { openUnlinkModal } = useCourseAuthoringContext(); const messageValues = { name: displayName, } - if (upstreamInfo.errorMessage) { + const handleUnlinkClick = () => { + if (!selectedSectionId) { + return; + } + openUnlinkModal({ value: blockData, sectionId: selectedSectionId }); + }; + + if (upstreamInfo?.errorMessage) { return ( - ); } - if (upstreamInfo.readyToSync) { + if (upstreamInfo?.readyToSync) { return ( @@ -85,7 +113,7 @@ const TopLevelTextAndButton = ({ upstreamInfo, displayName }: SubProps) => { ); } - if (upstreamInfo.downstreamCustomized.length > 0) { + if ((upstreamInfo?.downstreamCustomized.length || 0) > 0) { return ( ); @@ -99,24 +127,32 @@ interface Props { } export const LibraryReferenceCard = ({ itemId }: Props) => { - const { data: itemData, isLoading } = useCourseItemData(itemId); + const { data: itemData, isPending } = useCourseItemData(itemId); if (!itemData?.upstreamInfo?.upstreamRef) { return null; } return ( - - - - - -

+
+ + + + + +

+
+ +
- - - -
-
+ + +
) }; diff --git a/src/course-outline/outline-sidebar/OutlineSidebarContext.tsx b/src/course-outline/outline-sidebar/OutlineSidebarContext.tsx index bd007c5e7c..db020c7c28 100644 --- a/src/course-outline/outline-sidebar/OutlineSidebarContext.tsx +++ b/src/course-outline/outline-sidebar/OutlineSidebarContext.tsx @@ -33,6 +33,7 @@ interface OutlineSidebarContextData { open: () => void; toggle: () => void; selectedContainerId?: string; + selectedSectionId?: string; // The Id of the container used in the current sidebar page // The container is not necessarily selected to open a selected sidebar. // Example: Align sidebar @@ -53,6 +54,7 @@ export const OutlineSidebarProvider = ({ children }: { children?: React.ReactNod const [currentFlow, setCurrentFlow] = useState(null); const [isOpen, open, , toggle] = useToggle(true); + const [selectedSectionId, setSelectedSectionId] = useState(); const [selectedContainerId, setSelectedContainerId] = useState(); /** @@ -70,12 +72,20 @@ export const OutlineSidebarProvider = ({ children }: { children?: React.ReactNod open(); }, [open, setCurrentFlow]); - const openContainerInfoSidebar = useCallback((containerId: string) => { + const openContainerInfoSidebar = useCallback((containerId: string, sectionId?: string) => { if (isOutlineNewDesignEnabled()) { setSelectedContainerId(containerId); + if (sectionId) { + setSelectedSectionId(sectionId); + } setCurrentPageKey('info'); } - }, [setSelectedContainerId]); + }, [setSelectedContainerId, setSelectedSectionId]); + + const clearSelection = useCallback(() => { + setSelectedSectionId(undefined); + setSelectedContainerId(undefined); + }, [setSelectedSectionId, selectedContainerId]); /** * Starts add content flow. @@ -91,24 +101,11 @@ export const OutlineSidebarProvider = ({ children }: { children?: React.ReactNod onEscape: () => { stopCurrentFlow(); setSelectedContainerId(undefined); + setSelectedSectionId(undefined); }, dependency: [stopCurrentFlow, selectedContainerId], }); - useEffect(() => { - const handleEsc = (event: KeyboardEvent) => { - if (event.key === 'Escape') { - stopCurrentFlow(); - setSelectedContainerId(undefined); - } - }; - window.addEventListener('keydown', handleEsc); - - return () => { - window.removeEventListener('keydown', handleEsc); - }; - }, []); - const context = useMemo( () => ({ currentPageKey, @@ -121,7 +118,9 @@ export const OutlineSidebarProvider = ({ children }: { children?: React.ReactNod toggle, selectedContainerId, currentContainerId, + selectedSectionId, openContainerInfoSidebar, + clearSelection, }), [ currentPageKey, @@ -134,7 +133,9 @@ export const OutlineSidebarProvider = ({ children }: { children?: React.ReactNod toggle, selectedContainerId, currentContainerId, + selectedSectionId, openContainerInfoSidebar, + clearSelection, ], ); diff --git a/src/course-outline/outline-sidebar/SectionInfoSidebar.tsx b/src/course-outline/outline-sidebar/SectionInfoSidebar.tsx index f42376c315..a3f8dffac3 100644 --- a/src/course-outline/outline-sidebar/SectionInfoSidebar.tsx +++ b/src/course-outline/outline-sidebar/SectionInfoSidebar.tsx @@ -29,8 +29,8 @@ const SectionInfoSidebar = ({ sectionId }: Props) => { return ( <> + - { return ( <> + - {componentData && } diff --git a/src/course-outline/outline-sidebar/messages.ts b/src/course-outline/outline-sidebar/messages.ts index 09d3cacfab..dd8039c7c2 100644 --- a/src/course-outline/outline-sidebar/messages.ts +++ b/src/course-outline/outline-sidebar/messages.ts @@ -130,6 +130,11 @@ const messages = defineMessages({ defaultMessage: 'Section Content Summary', description: 'Title of the summary section in the section info sidebar', }, + subsectionContentSummaryText: { + id: 'course-authoring.course-outline.sidebar.subsection.content-summary-text', + defaultMessage: 'Subsection Content Summary', + description: 'Title of the summary section in the subsection info sidebar', + }, publishContainerButton: { id: 'course-authoring.course-outline.sidebar.generic.publish.button', defaultMessage: 'Publish Changes', diff --git a/src/course-outline/section-card/SectionCard.tsx b/src/course-outline/section-card/SectionCard.tsx index 84372568de..96cad7237a 100644 --- a/src/course-outline/section-card/SectionCard.tsx +++ b/src/course-outline/section-card/SectionCard.tsx @@ -239,7 +239,7 @@ const SectionCard = ({ const onClickCard = useCallback((e: React.MouseEvent, preventNodeEvents: boolean) => { if (!preventNodeEvents || e.target === e.currentTarget) { - openContainerInfoSidebar(section.id); + openContainerInfoSidebar(section.id, section.id); setIsExpanded(true); } }, [openContainerInfoSidebar]); diff --git a/src/course-outline/subsection-card/SubsectionCard.tsx b/src/course-outline/subsection-card/SubsectionCard.tsx index 6a8da4b8a2..caec75a869 100644 --- a/src/course-outline/subsection-card/SubsectionCard.tsx +++ b/src/course-outline/subsection-card/SubsectionCard.tsx @@ -241,7 +241,7 @@ const SubsectionCard = ({ const onClickCard = useCallback((e: React.MouseEvent, preventNodeEvents: boolean) => { if (!preventNodeEvents || e.target === e.currentTarget) { - openContainerInfoSidebar(subsection.id); + openContainerInfoSidebar(subsection.id, section.id); setIsExpanded(true); } }, [openContainerInfoSidebar]); diff --git a/src/course-outline/unit-card/UnitCard.tsx b/src/course-outline/unit-card/UnitCard.tsx index 80d6ba8a29..eca75ed522 100644 --- a/src/course-outline/unit-card/UnitCard.tsx +++ b/src/course-outline/unit-card/UnitCard.tsx @@ -168,7 +168,7 @@ const UnitCard = ({ const onClickCard = useCallback((e: React.MouseEvent) => { if (e.target === e.currentTarget) { - openContainerInfoSidebar(unit.id); + openContainerInfoSidebar(unit.id, section.id); } }, [openContainerInfoSidebar]); diff --git a/src/generic/sidebar/SidebarContent.tsx b/src/generic/sidebar/SidebarContent.tsx index fcd28a5523..155c43e0c8 100644 --- a/src/generic/sidebar/SidebarContent.tsx +++ b/src/generic/sidebar/SidebarContent.tsx @@ -28,7 +28,7 @@ interface SidebarContentProps { * ``` */ export const SidebarContent = ({ children } : SidebarContentProps) => ( - + {Array.isArray(children) ? children.map((child, index) => ( // eslint-disable-next-line react/no-array-index-key diff --git a/src/generic/unlink-modal/data/apiHooks.ts b/src/generic/unlink-modal/data/apiHooks.ts index 8cf7639cc8..40f3ce80c7 100644 --- a/src/generic/unlink-modal/data/apiHooks.ts +++ b/src/generic/unlink-modal/data/apiHooks.ts @@ -3,6 +3,7 @@ import { courseLibrariesQueryKeys } from '@src/course-libraries'; import { getCourseKey } from '@src/generic/key-utils'; import { unlinkDownstream } from './api'; +import { courseOutlineQueryKeys } from '@src/course-outline/data/apiHooks'; export const useUnlinkDownstream = () => { const queryClient = useQueryClient(); @@ -13,6 +14,9 @@ export const useUnlinkDownstream = () => { queryClient.invalidateQueries({ queryKey: courseLibrariesQueryKeys.courseLibraries(courseKey), }); + queryClient.invalidateQueries({ + queryKey: courseOutlineQueryKeys.course(courseKey), + }); }, }); }; From 88738216b5c2c345a1e90f07a47b6ee57793257f Mon Sep 17 00:00:00 2001 From: Navin Karkera Date: Tue, 20 Jan 2026 11:36:58 +0530 Subject: [PATCH 05/60] feat: handle sync issues in various situations --- src/CourseAuthoringRoutes.tsx | 10 +- src/course-outline/CourseOutline.tsx | 5 +- src/course-outline/data/api.ts | 6 +- src/course-outline/data/apiHooks.ts | 13 ++- src/course-outline/data/types.ts | 13 +++ .../header-navigations/HeaderActions.test.tsx | 2 +- src/course-outline/hooks.jsx | 2 + src/course-outline/index.ts | 1 + .../outline-sidebar/LibraryReferenceCard.tsx | 96 +++++++++++++++++-- .../section-card/SectionCard.test.tsx | 1 - .../subsection-card/SubsectionCard.test.tsx | 1 - 11 files changed, 127 insertions(+), 23 deletions(-) diff --git a/src/CourseAuthoringRoutes.tsx b/src/CourseAuthoringRoutes.tsx index 9bdcbeb75f..3a330377bb 100644 --- a/src/CourseAuthoringRoutes.tsx +++ b/src/CourseAuthoringRoutes.tsx @@ -11,7 +11,11 @@ import VideoSelectorContainer from './selectors/VideoSelectorContainer'; import CustomPages from './custom-pages'; import { FilesPage, VideosPage } from './files-and-videos'; import { AdvancedSettings } from './advanced-settings'; -import { CourseOutline, OutlineSidebarPagesProvider } from './course-outline'; +import { + CourseOutline, + OutlineSidebarProvider, + OutlineSidebarPagesProvider, +} from './course-outline'; import ScheduleAndDetails from './schedule-and-details'; import { GradingSettings } from './grading-settings'; import CourseTeam from './course-team/CourseTeam'; @@ -61,7 +65,9 @@ const CourseAuthoringRoutes = () => { element={( - + + + )} diff --git a/src/course-outline/CourseOutline.tsx b/src/course-outline/CourseOutline.tsx index 8af2d7ff49..3453ffabfe 100644 --- a/src/course-outline/CourseOutline.tsx +++ b/src/course-outline/CourseOutline.tsx @@ -61,7 +61,6 @@ import messages from './messages'; import headerMessages from './header-navigations/messages'; import { getTagsExportFile } from './data/api'; import OutlineAddChildButtons from './OutlineAddChildButtons'; -import { OutlineSidebarProvider } from './outline-sidebar/OutlineSidebarContext'; import { StatusBar } from './status-bar/StatusBar'; import { LegacyStatusBar } from './status-bar/LegacyStatusBar'; import { isOutlineNewDesignEnabled } from './utils'; @@ -268,7 +267,7 @@ const CourseOutline = () => { } return ( - + <> {getPageHeadTitle(courseName, intl.formatMessage(messages.headingTitle))} @@ -564,7 +563,7 @@ const CourseOutline = () => { {toastMessage} )} - + ); }; diff --git a/src/course-outline/data/api.ts b/src/course-outline/data/api.ts index f07d64cb5e..2b82a6c9ab 100644 --- a/src/course-outline/data/api.ts +++ b/src/course-outline/data/api.ts @@ -1,7 +1,7 @@ import { camelCaseObject, getConfig } from '@edx/frontend-platform'; import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; import { XBlock } from '@src/data/types'; -import { CourseOutline, CourseDetails } from './types'; +import { CourseOutline, CourseDetails, CourseItemUpdateResult } from './types'; const getApiBaseUrl = () => getConfig().STUDIO_BASE_URL; @@ -337,12 +337,12 @@ export async function configureCourseUnit( * Edit course section * @param {string} itemId * @param {string} displayName - * @returns {Promise} + * @returns {Promise} */ export async function editItemDisplayName( itemId: string, displayName: string, -): Promise { +): Promise { const { data } = await getAuthenticatedHttpClient() .post(getCourseItemApiUrl(itemId), { metadata: { diff --git a/src/course-outline/data/apiHooks.ts b/src/course-outline/data/apiHooks.ts index a739310437..00832f2a68 100644 --- a/src/course-outline/data/apiHooks.ts +++ b/src/course-outline/data/apiHooks.ts @@ -1,6 +1,7 @@ +import { CourseItemUpdateResult } from '@src/course-outline/data/types'; import { getCourseKey } from '@src/generic/key-utils'; import { skipToken, useMutation, useQuery } from '@tanstack/react-query'; -import { createCourseXblock, getCourseDetails, getCourseItem } from './api'; +import { createCourseXblock, editItemDisplayName, getCourseDetails, getCourseItem } from './api'; export const courseOutlineQueryKeys = { all: ['courseOutline'], @@ -44,7 +45,7 @@ export const useCreateCourseBlock = ( export const useCourseItemData = (itemId?: string, enabled: boolean = true) => ( useQuery({ queryKey: courseOutlineQueryKeys.courseItemId(itemId), - queryFn: enabled && itemId !== undefined ? () => getCourseItem(itemId!) : skipToken, + queryFn: enabled && itemId ? () => getCourseItem(itemId!) : skipToken, }) ); @@ -54,3 +55,11 @@ export const useCourseDetails = (courseId?: string, enabled: boolean = true) => queryFn: enabled && courseId ? () => getCourseDetails(courseId) : skipToken, }) ); + +export const useUpdateCourseBlockName = () => useMutation({ + mutationFn: editItemDisplayName, + onSettled: async () => { + + } +}); + diff --git a/src/course-outline/data/types.ts b/src/course-outline/data/types.ts index ea0b7496f3..4199ea85f4 100644 --- a/src/course-outline/data/types.ts +++ b/src/course-outline/data/types.ts @@ -80,3 +80,16 @@ export interface CourseOutlineState { pasteFileNotices: object; createdOn: null | Date; } + +export interface CourseItemUpdateResult { + id: string; + data?: object | null; + metadata: { + downstreamCustomized?: string[]; + topLevelDownstreamParentKey?: string; + upstream?: string; + upstreamDisplayName?: string; + upstreamVersion?: number; + displayName?: string; + } +} diff --git a/src/course-outline/header-navigations/HeaderActions.test.tsx b/src/course-outline/header-navigations/HeaderActions.test.tsx index c992548c34..54b12168ea 100644 --- a/src/course-outline/header-navigations/HeaderActions.test.tsx +++ b/src/course-outline/header-navigations/HeaderActions.test.tsx @@ -5,7 +5,7 @@ import { import messages from './messages'; import HeaderActions, { HeaderActionsProps } from './HeaderActions'; -import { OutlineSidebarProvider } from '../outline-sidebar/OutlineSidebarContext'; +import { OutlineSidebarProvider } from '@src/course-outline'; const headerNavigationsActions = { lmsLink: '', diff --git a/src/course-outline/hooks.jsx b/src/course-outline/hooks.jsx index b82e68cb49..bf1c2dd3da 100644 --- a/src/course-outline/hooks.jsx +++ b/src/course-outline/hooks.jsx @@ -59,6 +59,7 @@ import { } from './data/thunk'; import { containerComparisonQueryKeys } from '../container-comparison/data/apiHooks'; import { useOutlineSidebarContext } from '@src/course-outline/outline-sidebar/OutlineSidebarContext'; +import { courseOutlineQueryKeys } from '@src/course-outline/data/apiHooks'; const useCourseOutline = ({ courseId }) => { const dispatch = useDispatch(); @@ -189,6 +190,7 @@ const useCourseOutline = ({ courseId }) => { dispatch(editCourseItemQuery(itemId, sectionId, displayName)); // Invalidate container diff queries to update sync diff preview queryClient.invalidateQueries({ queryKey: containerComparisonQueryKeys.course(courseId) }); + queryClient.invalidateQueries({ queryKey: courseOutlineQueryKeys.course(courseId) }); }; const handleDeleteItemSubmit = () => { diff --git a/src/course-outline/index.ts b/src/course-outline/index.ts index 113a93daaa..ee83abdfbf 100644 --- a/src/course-outline/index.ts +++ b/src/course-outline/index.ts @@ -1,2 +1,3 @@ export { default as CourseOutline } from './CourseOutline'; export { OutlineSidebarPagesProvider } from './outline-sidebar/OutlineSidebarPagesContext'; +export { OutlineSidebarProvider } from './outline-sidebar/OutlineSidebarContext'; diff --git a/src/course-outline/outline-sidebar/LibraryReferenceCard.tsx b/src/course-outline/outline-sidebar/LibraryReferenceCard.tsx index 5fe2a0b28e..b065b1649e 100644 --- a/src/course-outline/outline-sidebar/LibraryReferenceCard.tsx +++ b/src/course-outline/outline-sidebar/LibraryReferenceCard.tsx @@ -1,19 +1,27 @@ import { FormattedMessage } from "@edx/frontend-platform/i18n"; import { Button, Card, Icon, Stack } from "@openedx/paragon"; import { Cached, LinkOff, Newsstand } from "@openedx/paragon/icons"; -import { useCourseItemData } from "@src/course-outline/data/apiHooks"; +import { invalidateLinksQuery } from "@src/course-libraries/data/apiHooks"; +import { courseOutlineQueryKeys, useCourseItemData } from "@src/course-outline/data/apiHooks"; +import { fetchCourseSectionQuery } from "@src/course-outline/data/thunk"; import { useOutlineSidebarContext } from "@src/course-outline/outline-sidebar/OutlineSidebarContext"; +import { PreviewLibraryXBlockChanges } from "@src/course-unit/preview-changes"; import { useCourseAuthoringContext } from "@src/CourseAuthoringContext"; import { XBlock } from "@src/data/types"; import { getBlockType, normalizeContainerType } from "@src/generic/key-utils"; +import { useToggleWithValue } from "@src/hooks"; +import { useQueryClient } from "@tanstack/react-query"; +import { useCallback, useMemo } from "react"; +import { useDispatch } from "react-redux"; import messages from './messages'; interface SubProps { blockData: XBlock; displayName: string; + openSyncModal: (val: XBlock) => void; } -const HasTopParentTextAndButton = ({ blockData, displayName }: SubProps) => { +const HasTopParentTextAndButton = ({ blockData, displayName, openSyncModal }: SubProps) => { const { upstreamInfo } = blockData; const { selectedSectionId } = useOutlineSidebarContext(); const { openContainerInfoSidebar } = useOutlineSidebarContext(); @@ -27,6 +35,13 @@ const HasTopParentTextAndButton = ({ blockData, displayName }: SubProps) => { openUnlinkModal({ value: parentData, sectionId: selectedSectionId }); }; + const handleSyncClick = () => { + if (!parentData) { + return; + } + openSyncModal(parentData); + }; + if (!upstreamInfo?.topLevelParentKey) { return null; } @@ -52,11 +67,15 @@ const HasTopParentTextAndButton = ({ blockData, displayName }: SubProps) => { ); } - if (upstreamInfo.readyToSync) { + if (parentData?.upstreamInfo?.readyToSync) { return ( - @@ -68,7 +87,9 @@ const HasTopParentTextAndButton = ({ blockData, displayName }: SubProps) => { @@ -76,7 +97,7 @@ const HasTopParentTextAndButton = ({ blockData, displayName }: SubProps) => { ); } -const TopLevelTextAndButton = ({ blockData, displayName }: SubProps) => { +const TopLevelTextAndButton = ({ blockData, displayName, openSyncModal }: SubProps) => { const { upstreamInfo } = blockData; const { selectedSectionId } = useOutlineSidebarContext(); const { openUnlinkModal } = useCourseAuthoringContext(); @@ -91,11 +112,19 @@ const TopLevelTextAndButton = ({ blockData, displayName }: SubProps) => { openUnlinkModal({ value: blockData, sectionId: selectedSectionId }); }; + const handleSyncClick = () => { + openSyncModal(blockData); + }; + if (upstreamInfo?.errorMessage) { return ( - @@ -106,7 +135,11 @@ const TopLevelTextAndButton = ({ blockData, displayName }: SubProps) => { return ( - @@ -128,10 +161,43 @@ interface Props { export const LibraryReferenceCard = ({ itemId }: Props) => { const { data: itemData, isPending } = useCourseItemData(itemId); + const { selectedSectionId } = useOutlineSidebarContext(); + const { courseId } = useCourseAuthoringContext(); + const [isSyncModalOpen, syncModalData, openSyncModal, closeSyncModal] = useToggleWithValue(); + const dispatch = useDispatch(); + const queryClient = useQueryClient(); if (!itemData?.upstreamInfo?.upstreamRef) { return null; } + const blockSyncData = useMemo(() => { + if (!syncModalData?.upstreamInfo?.readyToSync) { + return undefined; + } + return { + displayName: syncModalData.displayName, + downstreamBlockId: syncModalData.id, + upstreamBlockId: syncModalData.upstreamInfo.upstreamRef, + upstreamBlockVersionSynced: syncModalData.upstreamInfo.versionSynced, + isReadyToSyncIndividually: syncModalData.upstreamInfo.isReadyToSyncIndividually, + isContainer: syncModalData.category === 'vertical' || syncModalData.category === 'sequential' || syncModalData.category === 'chapter', + blockType: normalizeContainerType(syncModalData.category), + }; + }, [syncModalData]); + + const handleOnPostChangeSync = useCallback(() => { + if (selectedSectionId) { + dispatch(fetchCourseSectionQuery([selectedSectionId])); + } + if (courseId) { + invalidateLinksQuery(queryClient, courseId); + queryClient.invalidateQueries({ + queryKey: courseOutlineQueryKeys.course(courseId), + }); + } + }, [dispatch, selectedSectionId, queryClient, courseId]); + + return (
@@ -141,17 +207,27 @@ export const LibraryReferenceCard = ({ itemId }: Props) => {

- -
+ {blockSyncData && ( + + )}
) }; diff --git a/src/course-outline/section-card/SectionCard.test.tsx b/src/course-outline/section-card/SectionCard.test.tsx index 22d4c30821..824fefc3f0 100644 --- a/src/course-outline/section-card/SectionCard.test.tsx +++ b/src/course-outline/section-card/SectionCard.test.tsx @@ -102,7 +102,6 @@ const renderComponent = (props?: object, entry = '/course/:courseId') => render( onOpenPublishModal={jest.fn()} onOpenHighlightsModal={jest.fn()} onOpenDeleteModal={jest.fn()} - onOpenUnlinkModal={jest.fn()} onOpenConfigureModal={jest.fn()} onEditSectionSubmit={onEditSectionSubmit} onDuplicateSubmit={jest.fn()} diff --git a/src/course-outline/subsection-card/SubsectionCard.test.tsx b/src/course-outline/subsection-card/SubsectionCard.test.tsx index 180ccf3078..b7943b8eca 100644 --- a/src/course-outline/subsection-card/SubsectionCard.test.tsx +++ b/src/course-outline/subsection-card/SubsectionCard.test.tsx @@ -129,7 +129,6 @@ const renderComponent = (props?: object, entry = '/course/:courseId') => render( onOrderChange={jest.fn()} onOpenPublishModal={jest.fn()} onOpenDeleteModal={jest.fn()} - onOpenUnlinkModal={jest.fn()} isCustomRelativeDatesActive={false} onEditSubmit={onEditSubectionSubmit} onDuplicateSubmit={jest.fn()} From 7f6621a8ed105645459cc8f23538d0b3365151e0 Mon Sep 17 00:00:00 2001 From: Navin Karkera Date: Tue, 20 Jan 2026 13:04:15 +0530 Subject: [PATCH 06/60] refactor: simplify container edit in outline --- src/course-outline/CourseOutline.tsx | 4 -- src/course-outline/card-header/CardHeader.tsx | 52 +++++++++++-------- src/course-outline/data/api.ts | 11 ++-- src/course-outline/data/apiHooks.ts | 24 +++++---- src/course-outline/data/thunk.ts | 20 ------- src/course-outline/hooks.jsx | 13 ----- .../outline-sidebar/OutlineSidebarContext.tsx | 2 +- .../section-card/SectionCard.tsx | 34 ++---------- .../subsection-card/SubsectionCard.tsx | 36 +++---------- src/course-outline/unit-card/UnitCard.tsx | 38 ++++---------- src/hooks.ts | 4 +- 11 files changed, 72 insertions(+), 166 deletions(-) diff --git a/src/course-outline/CourseOutline.tsx b/src/course-outline/CourseOutline.tsx index 3453ffabfe..b7e3ad7683 100644 --- a/src/course-outline/CourseOutline.tsx +++ b/src/course-outline/CourseOutline.tsx @@ -114,7 +114,6 @@ const CourseOutline = () => { handleHighlightsFormSubmit, handleConfigureItemSubmit, handlePublishItemSubmit, - handleEditSubmit, handleDeleteItemSubmit, handleDuplicateSectionSubmit, handleDuplicateSubsectionSubmit, @@ -388,7 +387,6 @@ const CourseOutline = () => { onOpenPublishModal={openPublishModal} onOpenConfigureModal={openConfigureModal} onOpenDeleteModal={openDeleteModal} - onEditSectionSubmit={handleEditSubmit} onDuplicateSubmit={handleDuplicateSectionSubmit} isSectionsExpanded={isSectionsExpanded} onOrderChange={updateSectionOrderByIndex} @@ -417,7 +415,6 @@ const CourseOutline = () => { savingStatus={savingStatus} onOpenPublishModal={openPublishModal} onOpenDeleteModal={openDeleteModal} - onEditSubmit={handleEditSubmit} onDuplicateSubmit={handleDuplicateSubsectionSubmit} onOpenConfigureModal={openConfigureModal} onOrderChange={updateSubsectionOrderByIndex} @@ -450,7 +447,6 @@ const CourseOutline = () => { onOpenPublishModal={openPublishModal} onOpenConfigureModal={openConfigureModal} onOpenDeleteModal={openDeleteModal} - onEditSubmit={handleEditSubmit} onDuplicateSubmit={handleDuplicateUnitSubmit} onOrderChange={updateUnitOrderByIndex} discussionsSettings={discussionsSettings} diff --git a/src/course-outline/card-header/CardHeader.tsx b/src/course-outline/card-header/CardHeader.tsx index 1fcf35a05d..ed4cc8dcf2 100644 --- a/src/course-outline/card-header/CardHeader.tsx +++ b/src/course-outline/card-header/CardHeader.tsx @@ -21,29 +21,26 @@ import { } from '@openedx/paragon/icons'; import { useContentTagsCount } from '@src/generic/data/apiHooks'; +import { ContentTagsDrawerSheet } from '@src/content-tags-drawer'; import TagCount from '@src/generic/tag-count'; import { useEscapeClick } from '@src/hooks'; import { XBlockActions } from '@src/data/types'; -import { RequestStatus, RequestStatusType } from '@src/data/constants'; -import { ContentTagsDrawerSheet } from '@src/content-tags-drawer'; import { ITEM_BADGE_STATUS } from '../constants'; import { scrollToElement } from '../utils'; import CardStatus from './CardStatus'; import messages from './messages'; import { useOutlineSidebarContext } from '../outline-sidebar/OutlineSidebarContext'; +import { useUpdateCourseBlockName } from '@src/course-outline/data/apiHooks'; +import { useCourseAuthoringContext } from '@src/CourseAuthoringContext'; interface CardHeaderProps { title: string; status: string; - cardId?: string, + cardId: string, hasChanges: boolean; onClickPublish: () => void; onClickConfigure: () => void; onClickMenuButton: () => void; - onClickEdit: () => void; - isFormOpen: boolean; - onEditSubmit: (titleValue: string) => void; - closeForm: () => void; onClickDelete: () => void; onClickUnlink: () => void; onClickDuplicate: () => void; @@ -72,7 +69,6 @@ interface CardHeaderProps { extraActionsComponent?: ReactNode, onClickSync?: () => void; readyToSync?: boolean; - savingStatus?: RequestStatusType; } const CardHeader = ({ @@ -83,10 +79,6 @@ const CardHeader = ({ onClickPublish, onClickConfigure, onClickMenuButton, - onClickEdit, - isFormOpen, - onEditSubmit, - closeForm, onClickDelete, onClickUnlink, onClickDuplicate, @@ -107,7 +99,6 @@ const CardHeader = ({ extraActionsComponent, onClickSync, readyToSync, - savingStatus, }: CardHeaderProps) => { const intl = useIntl(); const [searchParams] = useSearchParams(); @@ -124,6 +115,8 @@ const CardHeader = ({ openLegacyTagsDrawer(); } }, [setCurrentPageKey, openLegacyTagsDrawer, cardId]); + const { courseId } = useCourseAuthoringContext(); + const [isFormOpen, openForm, closeForm] = useToggle(false); // Use studio url as base if proctoringExamConfigurationLink is a relative link const fullProctoringExamConfigurationLink = () => ( @@ -134,7 +127,6 @@ const CardHeader = ({ || status === ITEM_BADGE_STATUS.publishedNotLive) && !hasChanges; const { data: contentTagCount } = useContentTagsCount(cardId); - const isSaving = savingStatus === RequestStatus.IN_PROGRESS; useEffect(() => { const locatorId = searchParams.get('show'); @@ -163,9 +155,23 @@ const CardHeader = ({ setTitleValue(title); closeForm(); }, - dependency: title, + dependency: [title, closeForm, titleValue], }); + const editMutation = useUpdateCourseBlockName(courseId); + const handleEditSubmit = async (titleValue: string) => { + if (title !== titleValue) { + // both itemId and sectionId are same + await editMutation.mutateAsync({ + itemId: cardId, + displayName: titleValue, + }, { + onSuccess: closeForm, + }); + } + closeForm(); + }; + return ( <> { @@ -188,10 +194,10 @@ const CardHeader = ({ name="displayName" onChange={(e) => setTitleValue(e.target.value)} aria-label={intl.formatMessage(messages.editFieldAriaLabel)} - onBlur={() => onEditSubmit(titleValue)} - onKeyDown={/* istanbul ignore next */ (e) => { + onBlur={() => handleEditSubmit(titleValue)} + onKeyDown={(e) => { if (e.key === 'Enter') { - onEditSubmit(titleValue); + handleEditSubmit(titleValue); } else if (e.key === ' ') { // Avoid passing propagation to the `SortableItem` in the card, // which executes a `preventDefault`. If propagation is not prevented, @@ -199,7 +205,7 @@ const CardHeader = ({ e.stopPropagation(); } }} - disabled={isSaving} + disabled={editMutation.isPending} /> ) : ( @@ -211,9 +217,9 @@ const CardHeader = ({ alt={intl.formatMessage(messages.altButtonRename)} tooltipContent={
{intl.formatMessage(messages.altButtonRename)}
} iconAs={EditIcon} - onClick={onClickEdit} + onClick={openForm} // @ts-ignore - disabled={isSaving} + disabled={editMutation.isPending} /> )} @@ -265,7 +271,7 @@ const CardHeader = ({ {intl.formatMessage(messages.menuConfigure)} @@ -273,7 +279,7 @@ const CardHeader = ({ {getConfig().ENABLE_TAGGING_TAXONOMY_PAGES === 'true' && ( {intl.formatMessage(messages.menuManageTags)} diff --git a/src/course-outline/data/api.ts b/src/course-outline/data/api.ts index 2b82a6c9ab..31e999ac43 100644 --- a/src/course-outline/data/api.ts +++ b/src/course-outline/data/api.ts @@ -335,14 +335,11 @@ export async function configureCourseUnit( /** * Edit course section - * @param {string} itemId - * @param {string} displayName - * @returns {Promise} */ -export async function editItemDisplayName( - itemId: string, - displayName: string, -): Promise { +export async function editItemDisplayName({ itemId, displayName }: { + itemId: string; + displayName: string; +}): Promise { const { data } = await getAuthenticatedHttpClient() .post(getCourseItemApiUrl(itemId), { metadata: { diff --git a/src/course-outline/data/apiHooks.ts b/src/course-outline/data/apiHooks.ts index 00832f2a68..eb606bf2a3 100644 --- a/src/course-outline/data/apiHooks.ts +++ b/src/course-outline/data/apiHooks.ts @@ -1,6 +1,7 @@ -import { CourseItemUpdateResult } from '@src/course-outline/data/types'; +import { containerComparisonQueryKeys } from '@src/container-comparison/data/apiHooks'; +import type { XBlock } from '@src/data/types'; import { getCourseKey } from '@src/generic/key-utils'; -import { skipToken, useMutation, useQuery } from '@tanstack/react-query'; +import { skipToken, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { createCourseXblock, editItemDisplayName, getCourseDetails, getCourseItem } from './api'; export const courseOutlineQueryKeys = { @@ -42,8 +43,9 @@ export const useCreateCourseBlock = ( }, }); -export const useCourseItemData = (itemId?: string, enabled: boolean = true) => ( +export const useCourseItemData = (itemId?: string, initialData?: XBlock, enabled: boolean = true) => ( useQuery({ + initialData, queryKey: courseOutlineQueryKeys.courseItemId(itemId), queryFn: enabled && itemId ? () => getCourseItem(itemId!) : skipToken, }) @@ -56,10 +58,14 @@ export const useCourseDetails = (courseId?: string, enabled: boolean = true) => }) ); -export const useUpdateCourseBlockName = () => useMutation({ - mutationFn: editItemDisplayName, - onSettled: async () => { - - } -}); +export const useUpdateCourseBlockName = (courseId: string) => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: editItemDisplayName, + onSettled: async (_data, _err, variables) => { + queryClient.invalidateQueries({ queryKey: containerComparisonQueryKeys.course(courseId) }); + queryClient.invalidateQueries({ queryKey: courseOutlineQueryKeys.courseItemId(variables.itemId) }); + }, + }); +}; diff --git a/src/course-outline/data/thunk.ts b/src/course-outline/data/thunk.ts index 5c975fe247..81a7004a56 100644 --- a/src/course-outline/data/thunk.ts +++ b/src/course-outline/data/thunk.ts @@ -376,26 +376,6 @@ export function configureCourseUnitQuery( }; } -export function editCourseItemQuery(itemId: string, sectionId: string, displayName: string) { - return async (dispatch) => { - dispatch(updateSavingStatus({ status: RequestStatus.PENDING })); - dispatch(showProcessingNotification(NOTIFICATION_MESSAGES.saving)); - - try { - await editItemDisplayName(itemId, displayName).then(async (result) => { - if (result) { - await dispatch(fetchCourseSectionQuery([sectionId])); - dispatch(hideProcessingNotification()); - dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL })); - } - }); - } catch { - dispatch(hideProcessingNotification()); - dispatch(updateSavingStatus({ status: RequestStatus.FAILED })); - } - }; -} - /** * Generic function to delete course item, see below wrapper funcs for specific implementations. * @param {string} itemId diff --git a/src/course-outline/hooks.jsx b/src/course-outline/hooks.jsx index bf1c2dd3da..8cbf5a21d5 100644 --- a/src/course-outline/hooks.jsx +++ b/src/course-outline/hooks.jsx @@ -2,7 +2,6 @@ import { useEffect, useState } from 'react'; import { useDispatch, useSelector } from 'react-redux'; import { useToggle } from '@openedx/paragon'; import { getConfig } from '@edx/frontend-platform'; -import { useQueryClient } from '@tanstack/react-query'; import moment from 'moment'; import { getSavingStatus as getGenericSavingStatus } from '@src/generic/data/selectors'; @@ -35,7 +34,6 @@ import { deleteCourseSectionQuery, deleteCourseSubsectionQuery, deleteCourseUnitQuery, - editCourseItemQuery, duplicateSectionQuery, duplicateSubsectionQuery, duplicateUnitQuery, @@ -57,13 +55,10 @@ import { dismissNotificationQuery, syncDiscussionsTopics, } from './data/thunk'; -import { containerComparisonQueryKeys } from '../container-comparison/data/apiHooks'; import { useOutlineSidebarContext } from '@src/course-outline/outline-sidebar/OutlineSidebarContext'; -import { courseOutlineQueryKeys } from '@src/course-outline/data/apiHooks'; const useCourseOutline = ({ courseId }) => { const dispatch = useDispatch(); - const queryClient = useQueryClient(); const { handleAddSection } = useCourseAuthoringContext(); const { selectedContainerId, clearSelection } = useOutlineSidebarContext(); @@ -186,13 +181,6 @@ const useCourseOutline = ({ courseId }) => { handleConfigureModalClose(); }; - const handleEditSubmit = (itemId, sectionId, displayName) => { - dispatch(editCourseItemQuery(itemId, sectionId, displayName)); - // Invalidate container diff queries to update sync diff preview - queryClient.invalidateQueries({ queryKey: containerComparisonQueryKeys.course(courseId) }); - queryClient.invalidateQueries({ queryKey: courseOutlineQueryKeys.course(courseId) }); - }; - const handleDeleteItemSubmit = () => { switch (currentItem.category) { case COURSE_BLOCK_NAMES.chapter.id: @@ -317,7 +305,6 @@ const useCourseOutline = ({ courseId }) => { handleHighlightsFormSubmit, handleConfigureItemSubmit, handlePublishItemSubmit, - handleEditSubmit, statusBarData, isEnableHighlightsModalOpen, openEnableHighlightsModal, diff --git a/src/course-outline/outline-sidebar/OutlineSidebarContext.tsx b/src/course-outline/outline-sidebar/OutlineSidebarContext.tsx index db020c7c28..bad336d902 100644 --- a/src/course-outline/outline-sidebar/OutlineSidebarContext.tsx +++ b/src/course-outline/outline-sidebar/OutlineSidebarContext.tsx @@ -103,7 +103,7 @@ export const OutlineSidebarProvider = ({ children }: { children?: React.ReactNod setSelectedContainerId(undefined); setSelectedSectionId(undefined); }, - dependency: [stopCurrentFlow, selectedContainerId], + dependency: [stopCurrentFlow], }); const context = useMemo( diff --git a/src/course-outline/section-card/SectionCard.tsx b/src/course-outline/section-card/SectionCard.tsx index 96cad7237a..ac73d3c856 100644 --- a/src/course-outline/section-card/SectionCard.tsx +++ b/src/course-outline/section-card/SectionCard.tsx @@ -10,7 +10,6 @@ import classNames from 'classnames'; import { useQueryClient } from '@tanstack/react-query'; import { setCurrentItem, setCurrentSection } from '@src/course-outline/data/slice'; -import { RequestStatus, RequestStatusType } from '@src/data/constants'; import CardHeader from '@src/course-outline/card-header/CardHeader'; import SortableItem from '@src/course-outline/drag-helper/SortableItem'; import { DragContext } from '@src/course-outline/drag-helper/DragContextProvider'; @@ -27,6 +26,7 @@ import { invalidateLinksQuery } from '@src/course-libraries/data/apiHooks'; import { useCourseAuthoringContext } from '@src/CourseAuthoringContext'; import { useOutlineSidebarContext } from '@src/course-outline/outline-sidebar/OutlineSidebarContext'; import messages from './messages'; +import { useCourseItemData } from '@src/course-outline/data/apiHooks'; interface SectionCardProps { section: XBlock, @@ -36,8 +36,6 @@ interface SectionCardProps { onOpenHighlightsModal: (section: XBlock) => void, onOpenPublishModal: () => void, onOpenConfigureModal: () => void, - onEditSectionSubmit: (itemId: string, sectionId: string, displayName: string) => void, - savingStatus?: RequestStatusType, onOpenDeleteModal: () => void, onDuplicateSubmit: () => void, isSectionsExpanded: boolean, @@ -48,7 +46,7 @@ interface SectionCardProps { } const SectionCard = ({ - section, + section: initialData, isSelfPaced, isCustomRelativeDatesActive, children, @@ -57,8 +55,6 @@ const SectionCard = ({ onOpenHighlightsModal, onOpenPublishModal, onOpenConfigureModal, - onEditSectionSubmit, - savingStatus, onOpenDeleteModal, onDuplicateSubmit, isSectionsExpanded, @@ -71,9 +67,11 @@ const SectionCard = ({ const { selectedContainerId, openContainerInfoSidebar } = useOutlineSidebarContext(); const [searchParams] = useSearchParams(); const locatorId = searchParams.get('show'); - const isScrolledToElement = locatorId === section.id; const { courseId, openUnlinkModal } = useCourseAuthoringContext(); const queryClient = useQueryClient(); + // Set initialData state from course outline and subsequently depend on its own state + const { data: section = initialData } = useCourseItemData(initialData.id, initialData); + const isScrolledToElement = locatorId === section?.id; // Expand the section if a search result should be shown/scrolled to const containsSearchResult = () => { @@ -101,7 +99,6 @@ const SectionCard = ({ return false; }; const [isExpanded, setIsExpanded] = useState(containsSearchResult() || isSectionsExpanded); - const [isFormOpen, openForm, closeForm] = useToggle(false); const [isSyncModalOpen, openSyncModal, closeSyncModal] = useToggle(false); const namePrefix = 'section'; @@ -191,16 +188,6 @@ const SectionCard = ({ dispatch(setCurrentSection(section)); }; - const handleEditSubmit = (titleValue: string) => { - if (displayName !== titleValue) { - // both itemId and sectionId are same - onEditSectionSubmit(id, id, titleValue); - return; - } - - closeForm(); - }; - const handleOpenHighlightsModal = () => { onOpenHighlightsModal(section); }; @@ -213,12 +200,6 @@ const SectionCard = ({ onOrderChange(index, index + 1); }; - useEffect(() => { - if (savingStatus === RequestStatus.SUCCESSFUL) { - closeForm(); - } - }, [savingStatus]); - const titleComponent = ( openUnlinkModal({ value: section, sectionId: section.id })} onClickMoveUp={handleSectionMoveUp} onClickMoveDown={handleSectionMoveDown} onClickSync={openSyncModal} onClickCard={(e) => onClickCard(e, true)} - isFormOpen={isFormOpen} - closeForm={closeForm} - onEditSubmit={handleEditSubmit} - savingStatus={savingStatus} onClickDuplicate={onDuplicateSubmit} titleComponent={titleComponent} namePrefix={namePrefix} diff --git a/src/course-outline/subsection-card/SubsectionCard.tsx b/src/course-outline/subsection-card/SubsectionCard.tsx index caec75a869..644f46b07f 100644 --- a/src/course-outline/subsection-card/SubsectionCard.tsx +++ b/src/course-outline/subsection-card/SubsectionCard.tsx @@ -11,7 +11,6 @@ import { isEmpty } from 'lodash'; import CourseOutlineSubsectionCardExtraActionsSlot from '@src/plugin-slots/CourseOutlineSubsectionCardExtraActionsSlot'; import { setCurrentItem, setCurrentSection, setCurrentSubsection } from '@src/course-outline/data/slice'; -import { RequestStatus, RequestStatusType } from '@src/data/constants'; import CardHeader from '@src/course-outline/card-header/CardHeader'; import SortableItem from '@src/course-outline/drag-helper/SortableItem'; import { DragContext } from '@src/course-outline/drag-helper/DragContextProvider'; @@ -29,6 +28,7 @@ import { invalidateLinksQuery } from '@src/course-libraries/data/apiHooks'; import { useCourseAuthoringContext } from '@src/CourseAuthoringContext'; import { useOutlineSidebarContext } from '@src/course-outline/outline-sidebar/OutlineSidebarContext'; import messages from './messages'; +import { useCourseItemData } from '@src/course-outline/data/apiHooks'; interface SubsectionCardProps { section: XBlock, @@ -38,8 +38,6 @@ interface SubsectionCardProps { isSelfPaced: boolean, isCustomRelativeDatesActive: boolean, onOpenPublishModal: () => void, - onEditSubmit: (itemId: string, sectionId: string, displayName: string) => void, - savingStatus?: RequestStatusType, onOpenDeleteModal: () => void, onDuplicateSubmit: () => void, index: number, @@ -51,8 +49,8 @@ interface SubsectionCardProps { } const SubsectionCard = ({ - section, - subsection, + section: initialSectionData, + subsection: initialData, isSectionsExpanded, isSelfPaced, isCustomRelativeDatesActive, @@ -60,8 +58,6 @@ const SubsectionCard = ({ index, getPossibleMoves, onOpenPublishModal, - onEditSubmit, - savingStatus, onOpenDeleteModal, onDuplicateSubmit, onOrderChange, @@ -76,13 +72,15 @@ const SubsectionCard = ({ const { selectedContainerId, openContainerInfoSidebar } = useOutlineSidebarContext(); const [searchParams] = useSearchParams(); const locatorId = searchParams.get('show'); - const isScrolledToElement = locatorId === subsection.id; - const [isFormOpen, openForm, closeForm] = useToggle(false); const [isSyncModalOpen, openSyncModal, closeSyncModal] = useToggle(false); const namePrefix = 'subsection'; const { sharedClipboardData, showPasteUnit } = useClipboard(); const { courseId, openUnlinkModal } = useCourseAuthoringContext(); const queryClient = useQueryClient(); + // Set initialData state from course outline and subsequently depend on its own state + const { data: section = initialSectionData } = useCourseItemData(initialSectionData.id, initialSectionData); + const { data: subsection = initialData } = useCourseItemData(initialData.id, initialData); + const isScrolledToElement = locatorId === subsection.id; const { id, @@ -160,15 +158,6 @@ const SubsectionCard = ({ } }, [dispatch, section, queryClient, courseId]); - const handleEditSubmit = (titleValue: string) => { - if (displayName !== titleValue) { - onEditSubmit(id, section.id, titleValue); - return; - } - - closeForm(); - }; - const handleSubsectionMoveUp = () => { onOrderChange(section, moveUpDetails); }; @@ -226,12 +215,6 @@ const SubsectionCard = ({ setIsExpanded((prevState) => (containsSearchResult() || prevState)); }, [locatorId, setIsExpanded]); - useEffect(() => { - if (savingStatus === RequestStatus.SUCCESSFUL) { - closeForm(); - } - }, [savingStatus]); - const isDraggable = ( actions.draggable && (actions.allowMoveUp || actions.allowMoveDown) @@ -285,7 +268,6 @@ const SubsectionCard = ({ hasChanges={hasChanges} onClickMenuButton={handleClickMenuButton} onClickPublish={onOpenPublishModal} - onClickEdit={openForm} onClickDelete={onOpenDeleteModal} onClickUnlink={() => openUnlinkModal({ value: subsection, sectionId: section.id })} onClickMoveUp={handleSubsectionMoveUp} @@ -293,10 +275,6 @@ const SubsectionCard = ({ onClickConfigure={onOpenConfigureModal} onClickSync={openSyncModal} onClickCard={(e) => onClickCard(e, true)} - isFormOpen={isFormOpen} - closeForm={closeForm} - onEditSubmit={handleEditSubmit} - savingStatus={savingStatus} onClickDuplicate={onDuplicateSubmit} titleComponent={titleComponent} namePrefix={namePrefix} diff --git a/src/course-outline/unit-card/UnitCard.tsx b/src/course-outline/unit-card/UnitCard.tsx index eca75ed522..5cc044ecf8 100644 --- a/src/course-outline/unit-card/UnitCard.tsx +++ b/src/course-outline/unit-card/UnitCard.tsx @@ -14,7 +14,7 @@ import { useQueryClient } from '@tanstack/react-query'; import CourseOutlineUnitCardExtraActionsSlot from '@src/plugin-slots/CourseOutlineUnitCardExtraActionsSlot'; import { setCurrentItem, setCurrentSection, setCurrentSubsection } from '@src/course-outline/data/slice'; import { fetchCourseSectionQuery } from '@src/course-outline/data/thunk'; -import { RequestStatus, RequestStatusType } from '@src/data/constants'; +import { RequestStatusType } from '@src/data/constants'; import CardHeader from '@src/course-outline/card-header/CardHeader'; import SortableItem from '@src/course-outline/drag-helper/SortableItem'; import TitleLink from '@src/course-outline/card-header/TitleLink'; @@ -27,6 +27,7 @@ import { invalidateLinksQuery } from '@src/course-libraries/data/apiHooks'; import type { XBlock } from '@src/data/types'; import { useCourseAuthoringContext } from '@src/CourseAuthoringContext'; import { useOutlineSidebarContext } from '../outline-sidebar/OutlineSidebarContext'; +import { useCourseItemData } from '@src/course-outline/data/apiHooks'; interface UnitCardProps { unit: XBlock; @@ -34,7 +35,6 @@ interface UnitCardProps { section: XBlock; onOpenPublishModal: () => void; onOpenConfigureModal: () => void; - onEditSubmit: (itemId: string, sectionId: string, displayName: string) => void, savingStatus?: RequestStatusType; onOpenDeleteModal: () => void; onDuplicateSubmit: () => void; @@ -50,17 +50,15 @@ interface UnitCardProps { } const UnitCard = ({ - unit, - subsection, - section, + unit: initialData, + subsection: initialSubsectionData, + section: initialSectionData, isSelfPaced, isCustomRelativeDatesActive, index, getPossibleMoves, onOpenPublishModal, onOpenConfigureModal, - onEditSubmit, - savingStatus, onOpenDeleteModal, onDuplicateSubmit, onOrderChange, @@ -71,14 +69,16 @@ const UnitCard = ({ const [searchParams] = useSearchParams(); const { selectedContainerId, openContainerInfoSidebar } = useOutlineSidebarContext(); const locatorId = searchParams.get('show'); - const isScrolledToElement = locatorId === unit.id; - const [isFormOpen, openForm, closeForm] = useToggle(false); const [isSyncModalOpen, openSyncModal, closeSyncModal] = useToggle(false); const namePrefix = 'unit'; const { copyToClipboard } = useClipboard(); const { courseId, getUnitUrl, openUnlinkModal } = useCourseAuthoringContext(); const queryClient = useQueryClient(); + const { data: section = initialSectionData } = useCourseItemData(initialSectionData.id, initialSectionData); + const { data: subsection = initialSubsectionData } = useCourseItemData(initialSubsectionData.id, initialSubsectionData); + const { data: unit = initialData } = useCourseItemData(initialData.id, initialData); + const isScrolledToElement = locatorId === unit.id; const { id, @@ -137,15 +137,6 @@ const UnitCard = ({ dispatch(setCurrentSubsection(subsection)); }; - const handleEditSubmit = (titleValue: string) => { - if (displayName !== titleValue) { - onEditSubmit(id, section.id, titleValue); - return; - } - - closeForm(); - }; - const handleUnitMoveUp = () => { onOrderChange(section, moveUpDetails); }; @@ -204,12 +195,6 @@ const UnitCard = ({ } }, [isScrolledToElement]); - useEffect(() => { - if (savingStatus === RequestStatus.SUCCESSFUL) { - closeForm(); - } - }, [savingStatus]); - if (!isHeaderVisible) { return null; } @@ -257,17 +242,12 @@ const UnitCard = ({ onClickMenuButton={handleClickMenuButton} onClickPublish={onOpenPublishModal} onClickConfigure={onOpenConfigureModal} - onClickEdit={openForm} onClickDelete={onOpenDeleteModal} onClickUnlink={() => openUnlinkModal({ value: unit, sectionId: section.id })} onClickMoveUp={handleUnitMoveUp} onClickMoveDown={handleUnitMoveDown} onClickSync={openSyncModal} onClickCard={onClickCard} - isFormOpen={isFormOpen} - closeForm={closeForm} - onEditSubmit={handleEditSubmit} - savingStatus={savingStatus} onClickDuplicate={onDuplicateSubmit} titleComponent={titleComponent} namePrefix={namePrefix} diff --git a/src/hooks.ts b/src/hooks.ts index 1ee155ec81..368f253d3a 100644 --- a/src/hooks.ts +++ b/src/hooks.ts @@ -38,10 +38,10 @@ export const useEscapeClick = ({ onEscape, dependency }: { onEscape: () => void, } }; - window.addEventListener('keydown', handleEscapeClick); + document.addEventListener('keydown', handleEscapeClick); return () => { - window.removeEventListener('keydown', handleEscapeClick); + document.removeEventListener('keydown', handleEscapeClick); }; }, [dependency]); }; From d656b5987a7496ded51e34f52b4268773cbda013 Mon Sep 17 00:00:00 2001 From: Navin Karkera Date: Tue, 20 Jan 2026 20:11:34 +0530 Subject: [PATCH 07/60] refactor: outline children state syncing --- src/CourseAuthoringContext.tsx | 21 ++++-- src/course-outline/CourseOutline.tsx | 15 +--- src/course-outline/card-header/CardHeader.tsx | 8 +-- src/course-outline/data/api.ts | 8 +-- src/course-outline/data/apiHooks.ts | 31 +++++++-- src/course-outline/data/thunk.ts | 22 ------ src/course-outline/hooks.jsx | 12 ---- .../outline-sidebar/LibraryReferenceCard.tsx | 6 +- .../{PublishModal.jsx => PublishModal.tsx} | 69 ++++++++++++------- .../section-card/SectionCard.tsx | 14 ++-- .../subsection-card/SubsectionCard.tsx | 14 ++-- src/course-outline/unit-card/UnitCard.tsx | 14 ++-- 12 files changed, 125 insertions(+), 109 deletions(-) rename src/course-outline/publish-modal/{PublishModal.jsx => PublishModal.tsx} (54%) diff --git a/src/CourseAuthoringContext.tsx b/src/CourseAuthoringContext.tsx index 05ce784ff6..17359a8888 100644 --- a/src/CourseAuthoringContext.tsx +++ b/src/CourseAuthoringContext.tsx @@ -15,7 +15,7 @@ import { XBlock } from '@src/data/types'; import { useUnlinkDownstream } from '@src/generic/unlink-modal'; import { fetchCourseSectionQuery } from '@src/course-outline/data/thunk'; -type UnlinkModalState = { +type ModalState = { value: XBlock; sectionId: string; } @@ -33,10 +33,14 @@ export type CourseAuthoringContextData = { openUnitPage: (locator: string) => void; getUnitUrl: (locator: string) => string; isUnlinkModalOpen: boolean; - currentUnlinkModalData?: UnlinkModalState; - openUnlinkModal: (value: UnlinkModalState) => void; + currentUnlinkModalData?: ModalState; + openUnlinkModal: (value: ModalState) => void; closeUnlinkModal: () => void; handleUnlinkItemSubmit: () => Promise; + isPublishModalOpen: boolean; + currentPublishModalData?: ModalState; + openPublishModal: (value: ModalState) => void; + closePublishModal: () => void; }; /** @@ -64,7 +68,8 @@ export const CourseAuthoringProvider = ({ const canChangeProviders = getAuthenticatedUser().administrator || new Date(courseDetails?.start ?? 0) > new Date(); const { courseStructure } = useSelector(getOutlineIndexData); const { id: courseUsageKey } = courseStructure || {}; - const [isUnlinkModalOpen, currentUnlinkModalData, openUnlinkModal, closeUnlinkModal] = useToggleWithValue(); + const [isUnlinkModalOpen, currentUnlinkModalData, openUnlinkModal, closeUnlinkModal] = useToggleWithValue(); + const [isPublishModalOpen, currentPublishModalData, openPublishModal, closePublishModal] = useToggleWithValue(); const getUnitUrl = (locator: string) => { if (getConfig().ENABLE_UNIT_PAGE === 'true' && waffleFlags.useNewUnitPage) { @@ -150,6 +155,10 @@ export const CourseAuthoringProvider = ({ closeUnlinkModal, currentUnlinkModalData, handleUnlinkItemSubmit, + isPublishModalOpen, + currentPublishModalData, + openPublishModal, + closePublishModal, }), [ courseId, courseUsageKey, @@ -166,6 +175,10 @@ export const CourseAuthoringProvider = ({ closeUnlinkModal, currentUnlinkModalData, handleUnlinkItemSubmit, + isPublishModalOpen, + currentPublishModalData, + openPublishModal, + closePublishModal, ]); return ( diff --git a/src/course-outline/CourseOutline.tsx b/src/course-outline/CourseOutline.tsx index b7e3ad7683..f4e2742e88 100644 --- a/src/course-outline/CourseOutline.tsx +++ b/src/course-outline/CourseOutline.tsx @@ -95,14 +95,11 @@ const CourseOutline = () => { isInternetConnectionAlertFailed, isDisabledReindexButton, isHighlightsModalOpen, - isPublishModalOpen, isConfigureModalOpen, isDeleteModalOpen, closeHighlightsModal, - closePublishModal, handleConfigureModalClose, closeDeleteModal, - openPublishModal, openConfigureModal, openDeleteModal, headerNavigationsActions, @@ -113,7 +110,6 @@ const CourseOutline = () => { handleOpenHighlightsModal, handleHighlightsFormSubmit, handleConfigureItemSubmit, - handlePublishItemSubmit, handleDeleteItemSubmit, handleDuplicateSectionSubmit, handleDuplicateSubsectionSubmit, @@ -382,9 +378,7 @@ const CourseOutline = () => { canMoveItem={canMoveSection(sections)} isSelfPaced={statusBarData.isSelfPaced} isCustomRelativeDatesActive={isCustomRelativeDatesActive} - savingStatus={savingStatus} onOpenHighlightsModal={handleOpenHighlightsModal} - onOpenPublishModal={openPublishModal} onOpenConfigureModal={openConfigureModal} onOpenDeleteModal={openDeleteModal} onDuplicateSubmit={handleDuplicateSectionSubmit} @@ -412,8 +406,6 @@ const CourseOutline = () => { isSectionsExpanded={isSectionsExpanded} isSelfPaced={statusBarData.isSelfPaced} isCustomRelativeDatesActive={isCustomRelativeDatesActive} - savingStatus={savingStatus} - onOpenPublishModal={openPublishModal} onOpenDeleteModal={openDeleteModal} onDuplicateSubmit={handleDuplicateSubsectionSubmit} onOpenConfigureModal={openConfigureModal} @@ -444,7 +436,6 @@ const CourseOutline = () => { subsection.childInfo.children, )} savingStatus={savingStatus} - onOpenPublishModal={openPublishModal} onOpenConfigureModal={openConfigureModal} onOpenDeleteModal={openDeleteModal} onDuplicateSubmit={handleDuplicateUnitSubmit} @@ -504,11 +495,7 @@ const CourseOutline = () => { onClose={closeHighlightsModal} onSubmit={handleHighlightsFormSubmit} /> - + { + const handleEditSubmit = useCallback(async () => { if (title !== titleValue) { // both itemId and sectionId are same await editMutation.mutateAsync({ @@ -170,7 +170,7 @@ const CardHeader = ({ }); } closeForm(); - }; + }, [title, titleValue, cardId, editMutation]); return ( <> @@ -194,7 +194,7 @@ const CardHeader = ({ name="displayName" onChange={(e) => setTitleValue(e.target.value)} aria-label={intl.formatMessage(messages.editFieldAriaLabel)} - onBlur={() => handleEditSubmit(titleValue)} + onBlur={handleEditSubmit} onKeyDown={(e) => { if (e.key === 'Enter') { handleEditSubmit(titleValue); diff --git a/src/course-outline/data/api.ts b/src/course-outline/data/api.ts index 31e999ac43..6f57b9aaf4 100644 --- a/src/course-outline/data/api.ts +++ b/src/course-outline/data/api.ts @@ -201,13 +201,13 @@ export async function updateCourseSectionHighlights( } /** - * Publish course section - * @param {string} sectionId + * Publish course item + * @param {string} itemId * @returns {Promise} */ -export async function publishCourseSection(sectionId: string): Promise { +export async function publishCourseItem(itemId: string): Promise { const { data } = await getAuthenticatedHttpClient() - .post(getCourseItemApiUrl(sectionId), { + .post(getCourseItemApiUrl(itemId), { publish: 'make_public', }); diff --git a/src/course-outline/data/apiHooks.ts b/src/course-outline/data/apiHooks.ts index eb606bf2a3..08180e0590 100644 --- a/src/course-outline/data/apiHooks.ts +++ b/src/course-outline/data/apiHooks.ts @@ -2,7 +2,7 @@ import { containerComparisonQueryKeys } from '@src/container-comparison/data/api import type { XBlock } from '@src/data/types'; import { getCourseKey } from '@src/generic/key-utils'; import { skipToken, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; -import { createCourseXblock, editItemDisplayName, getCourseDetails, getCourseItem } from './api'; +import { createCourseXblock, editItemDisplayName, getCourseDetails, getCourseItem, publishCourseItem } from './api'; export const courseOutlineQueryKeys = { all: ['courseOutline'], @@ -36,12 +36,17 @@ export const courseOutlineQueryKeys = { */ export const useCreateCourseBlock = ( callback?: ((locator: string, parentLocator: string) => void), -) => useMutation({ - mutationFn: createCourseXblock, - onSettled: async (data: { locator: string }, _err, variables) => { - callback?.(data.locator, variables.parentLocator); - }, -}); +) => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: createCourseXblock, + onSettled: async (data: { locator: string; }, _err, variables) => { + // FIXME: invalidate section query + queryClient.invalidateQueries({ queryKey: courseOutlineQueryKeys.courseItemId(variables.parentLocator) }); + callback?.(data.locator, variables.parentLocator); + }, + }) +}; export const useCourseItemData = (itemId?: string, initialData?: XBlock, enabled: boolean = true) => ( useQuery({ @@ -63,9 +68,21 @@ export const useUpdateCourseBlockName = (courseId: string) => { return useMutation({ mutationFn: editItemDisplayName, onSettled: async (_data, _err, variables) => { + // FIXME: invalidate section query queryClient.invalidateQueries({ queryKey: containerComparisonQueryKeys.course(courseId) }); queryClient.invalidateQueries({ queryKey: courseOutlineQueryKeys.courseItemId(variables.itemId) }); }, }); }; +export const usePublishCourseItem = (sectionId?: string) => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: publishCourseItem, + onSettled: async (_data, _err, itemId) => { + queryClient.invalidateQueries({ queryKey: courseOutlineQueryKeys.courseItemId(itemId) }); + queryClient.invalidateQueries({ queryKey: courseOutlineQueryKeys.courseItemId(sectionId) }); + }, + }); +}; + diff --git a/src/course-outline/data/thunk.ts b/src/course-outline/data/thunk.ts index 81a7004a56..3be2d54270 100644 --- a/src/course-outline/data/thunk.ts +++ b/src/course-outline/data/thunk.ts @@ -13,13 +13,11 @@ import { getErrorDetails } from '../utils/getErrorDetails'; import { deleteCourseItem, duplicateCourseItem, - editItemDisplayName, enableCourseHighlightsEmails, getCourseBestPractices, getCourseLaunch, getCourseOutlineIndex, getCourseItem, - publishCourseSection, configureCourseSection, configureCourseSubsection, configureCourseUnit, @@ -266,26 +264,6 @@ export function updateCourseSectionHighlightsQuery(sectionId: string, highlights }; } -export function publishCourseItemQuery(itemId: string, sectionId: string) { - return async (dispatch) => { - dispatch(updateSavingStatus({ status: RequestStatus.PENDING })); - dispatch(showProcessingNotification(NOTIFICATION_MESSAGES.saving)); - - try { - await publishCourseSection(itemId).then(async (result) => { - if (result) { - await dispatch(fetchCourseSectionQuery([sectionId])); - dispatch(hideProcessingNotification()); - dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL })); - } - }); - } catch { - dispatch(hideProcessingNotification()); - dispatch(updateSavingStatus({ status: RequestStatus.FAILED })); - } - }; -} - export function configureCourseItemQuery(sectionId: string, configureFn: () => Promise) { return async (dispatch) => { dispatch(updateSavingStatus({ status: RequestStatus.PENDING })); diff --git a/src/course-outline/hooks.jsx b/src/course-outline/hooks.jsx index 8cbf5a21d5..2984beb6e9 100644 --- a/src/course-outline/hooks.jsx +++ b/src/course-outline/hooks.jsx @@ -42,7 +42,6 @@ import { fetchCourseLaunchQuery, fetchCourseOutlineIndexQuery, fetchCourseReindexQuery, - publishCourseItemQuery, updateCourseSectionHighlightsQuery, configureCourseSectionQuery, configureCourseSubsectionQuery, @@ -93,7 +92,6 @@ const useCourseOutline = ({ courseId }) => { const [isDisabledReindexButton, setDisableReindexButton] = useState(false); const [showSuccessAlert, setShowSuccessAlert] = 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); @@ -152,12 +150,6 @@ const useCourseOutline = ({ courseId }) => { closeHighlightsModal(); }; - const handlePublishItemSubmit = () => { - dispatch(publishCourseItemQuery(currentItem.id, currentSection.id)); - - closePublishModal(); - }; - const handleConfigureModalClose = () => { closeConfigureModal(); // reset the currentItem so the ConfigureModal's state is also reset @@ -294,9 +286,6 @@ const useCourseOutline = ({ courseId }) => { showSuccessAlert, isDisabledReindexButton, isSectionsExpanded, - isPublishModalOpen, - openPublishModal, - closePublishModal, isConfigureModalOpen, openConfigureModal, handleConfigureModalClose, @@ -304,7 +293,6 @@ const useCourseOutline = ({ courseId }) => { handleEnableHighlightsSubmit, handleHighlightsFormSubmit, handleConfigureItemSubmit, - handlePublishItemSubmit, statusBarData, isEnableHighlightsModalOpen, openEnableHighlightsModal, diff --git a/src/course-outline/outline-sidebar/LibraryReferenceCard.tsx b/src/course-outline/outline-sidebar/LibraryReferenceCard.tsx index b065b1649e..05f992d223 100644 --- a/src/course-outline/outline-sidebar/LibraryReferenceCard.tsx +++ b/src/course-outline/outline-sidebar/LibraryReferenceCard.tsx @@ -166,9 +166,6 @@ export const LibraryReferenceCard = ({ itemId }: Props) => { const [isSyncModalOpen, syncModalData, openSyncModal, closeSyncModal] = useToggleWithValue(); const dispatch = useDispatch(); const queryClient = useQueryClient(); - if (!itemData?.upstreamInfo?.upstreamRef) { - return null; - } const blockSyncData = useMemo(() => { if (!syncModalData?.upstreamInfo?.readyToSync) { @@ -197,6 +194,9 @@ export const LibraryReferenceCard = ({ itemId }: Props) => { } }, [dispatch, selectedSectionId, queryClient, courseId]); + if (!itemData?.upstreamInfo?.upstreamRef) { + return null; + } return (
diff --git a/src/course-outline/publish-modal/PublishModal.jsx b/src/course-outline/publish-modal/PublishModal.tsx similarity index 54% rename from src/course-outline/publish-modal/PublishModal.jsx rename to src/course-outline/publish-modal/PublishModal.tsx index b56488c050..5b749534ca 100644 --- a/src/course-outline/publish-modal/PublishModal.jsx +++ b/src/course-outline/publish-modal/PublishModal.tsx @@ -1,33 +1,60 @@ /* eslint-disable import/named */ -import React from 'react'; -import PropTypes from 'prop-types'; +import React, { useMemo } from 'react'; import { useIntl } from '@edx/frontend-platform/i18n'; import { ModalDialog, - Button, ActionRow, } from '@openedx/paragon'; -import { useSelector } from 'react-redux'; -import { getCurrentItem } from '../data/selectors'; import { COURSE_BLOCK_NAMES } from '../constants'; import messages from './messages'; +import { courseOutlineQueryKeys, usePublishCourseItem } from '@src/course-outline/data/apiHooks'; +import { XBlock } from '@src/data/types'; +import LoadingButton from '@src/generic/loading-button'; +import { useCourseAuthoringContext } from '@src/CourseAuthoringContext'; +import { useQueryClient } from '@tanstack/react-query'; -const PublishModal = ({ - isOpen, - onClose, - onPublishSubmit, -}) => { +const PublishModal = () => { const intl = useIntl(); - const { displayName, childInfo, category } = useSelector(getCurrentItem); + const { isPublishModalOpen, currentPublishModalData, closePublishModal } = useCourseAuthoringContext(); + const { id, displayName, childInfo, category } = currentPublishModalData?.value || {}; const categoryName = COURSE_BLOCK_NAMES[category]?.name.toLowerCase(); - const children = childInfo?.children || []; + const children: XBlock[] = childInfo?.children || []; + const publishMutation = usePublishCourseItem(currentPublishModalData?.sectionId) + const queryClient = useQueryClient(); + + const childrenIds = useMemo(() => children.reduce(( + result: string[], + current: XBlock + ): string[] => { + let grandChildren = current.childInfo?.children.filter((child) => child.hasChanges) || []; + let temp = [...result, ...grandChildren.map((child) => child.id)]; + if (current.hasChanges) { + temp.push(current.id); + } + return temp; + }, []), [children]) + + const onPublishSubmit = async () => { + if (id) { + await publishMutation.mutateAsync(id, { + onSettled: () => { + closePublishModal(); + // Update query client to refresh the data of all children blocks + childrenIds.forEach((blockId) => { + queryClient.invalidateQueries({ queryKey: courseOutlineQueryKeys.courseItemId(blockId) }); + }); + } + }) + } + }; return ( {intl.formatMessage(messages.cancelButton)} - + label={intl.formatMessage(messages.publishButton)} + /> + ); }; -PublishModal.propTypes = { - isOpen: PropTypes.bool.isRequired, - onClose: PropTypes.func.isRequired, - onPublishSubmit: PropTypes.func.isRequired, -}; - export default PublishModal; diff --git a/src/course-outline/section-card/SectionCard.tsx b/src/course-outline/section-card/SectionCard.tsx index ac73d3c856..cde2010c0f 100644 --- a/src/course-outline/section-card/SectionCard.tsx +++ b/src/course-outline/section-card/SectionCard.tsx @@ -26,7 +26,7 @@ import { invalidateLinksQuery } from '@src/course-libraries/data/apiHooks'; import { useCourseAuthoringContext } from '@src/CourseAuthoringContext'; import { useOutlineSidebarContext } from '@src/course-outline/outline-sidebar/OutlineSidebarContext'; import messages from './messages'; -import { useCourseItemData } from '@src/course-outline/data/apiHooks'; +import { courseOutlineQueryKeys, useCourseItemData } from '@src/course-outline/data/apiHooks'; interface SectionCardProps { section: XBlock, @@ -34,7 +34,6 @@ interface SectionCardProps { isCustomRelativeDatesActive: boolean, children: ReactNode, onOpenHighlightsModal: (section: XBlock) => void, - onOpenPublishModal: () => void, onOpenConfigureModal: () => void, onOpenDeleteModal: () => void, onDuplicateSubmit: () => void, @@ -53,7 +52,6 @@ const SectionCard = ({ index, canMoveItem, onOpenHighlightsModal, - onOpenPublishModal, onOpenConfigureModal, onOpenDeleteModal, onDuplicateSubmit, @@ -67,7 +65,7 @@ const SectionCard = ({ const { selectedContainerId, openContainerInfoSidebar } = useOutlineSidebarContext(); const [searchParams] = useSearchParams(); const locatorId = searchParams.get('show'); - const { courseId, openUnlinkModal } = useCourseAuthoringContext(); + const { courseId, openUnlinkModal, openPublishModal } = useCourseAuthoringContext(); const queryClient = useQueryClient(); // Set initialData state from course outline and subsequently depend on its own state const { data: section = initialData } = useCourseItemData(initialData.id, initialData); @@ -106,6 +104,12 @@ const SectionCard = ({ setIsExpanded(isSectionsExpanded); }, [isSectionsExpanded]); + /** + Temporary measure to keep the react-query state updated with redux state */ + useEffect(() => { + queryClient.setQueryData(courseOutlineQueryKeys.courseItemId(initialData.id), initialData); + }, [initialData]); + const { id, category, @@ -261,7 +265,7 @@ const SectionCard = ({ status={sectionStatus} hasChanges={hasChanges} onClickMenuButton={handleClickMenuButton} - onClickPublish={onOpenPublishModal} + onClickPublish={() => openPublishModal({ value: section, sectionId: section.id })} onClickConfigure={onOpenConfigureModal} onClickDelete={onOpenDeleteModal} onClickUnlink={() => openUnlinkModal({ value: section, sectionId: section.id })} diff --git a/src/course-outline/subsection-card/SubsectionCard.tsx b/src/course-outline/subsection-card/SubsectionCard.tsx index 644f46b07f..fe367b4d8f 100644 --- a/src/course-outline/subsection-card/SubsectionCard.tsx +++ b/src/course-outline/subsection-card/SubsectionCard.tsx @@ -28,7 +28,7 @@ import { invalidateLinksQuery } from '@src/course-libraries/data/apiHooks'; import { useCourseAuthoringContext } from '@src/CourseAuthoringContext'; import { useOutlineSidebarContext } from '@src/course-outline/outline-sidebar/OutlineSidebarContext'; import messages from './messages'; -import { useCourseItemData } from '@src/course-outline/data/apiHooks'; +import { courseOutlineQueryKeys, useCourseItemData } from '@src/course-outline/data/apiHooks'; interface SubsectionCardProps { section: XBlock, @@ -37,7 +37,6 @@ interface SubsectionCardProps { isSectionsExpanded: boolean, isSelfPaced: boolean, isCustomRelativeDatesActive: boolean, - onOpenPublishModal: () => void, onOpenDeleteModal: () => void, onDuplicateSubmit: () => void, index: number, @@ -57,7 +56,6 @@ const SubsectionCard = ({ children, index, getPossibleMoves, - onOpenPublishModal, onOpenDeleteModal, onDuplicateSubmit, onOrderChange, @@ -75,7 +73,7 @@ const SubsectionCard = ({ const [isSyncModalOpen, openSyncModal, closeSyncModal] = useToggle(false); const namePrefix = 'subsection'; const { sharedClipboardData, showPasteUnit } = useClipboard(); - const { courseId, openUnlinkModal } = useCourseAuthoringContext(); + const { courseId, openUnlinkModal, openPublishModal } = useCourseAuthoringContext(); const queryClient = useQueryClient(); // Set initialData state from course outline and subsequently depend on its own state const { data: section = initialSectionData } = useCourseItemData(initialSectionData.id, initialSectionData); @@ -141,6 +139,12 @@ const SubsectionCard = ({ setIsExpanded(isSectionsExpanded); }, [isSectionsExpanded]); + /** + Temporary measure to keep the react-query state updated with redux state */ + useEffect(() => { + queryClient.setQueryData(courseOutlineQueryKeys.courseItemId(initialData.id), initialData); + }, [initialData]); + const handleExpandContent = () => { setIsExpanded((prevState) => !prevState); }; @@ -267,7 +271,7 @@ const SubsectionCard = ({ cardId={id} hasChanges={hasChanges} onClickMenuButton={handleClickMenuButton} - onClickPublish={onOpenPublishModal} + onClickPublish={() => openPublishModal({ value: subsection, sectionId: section.id })} onClickDelete={onOpenDeleteModal} onClickUnlink={() => openUnlinkModal({ value: subsection, sectionId: section.id })} onClickMoveUp={handleSubsectionMoveUp} diff --git a/src/course-outline/unit-card/UnitCard.tsx b/src/course-outline/unit-card/UnitCard.tsx index 5cc044ecf8..bb229634f1 100644 --- a/src/course-outline/unit-card/UnitCard.tsx +++ b/src/course-outline/unit-card/UnitCard.tsx @@ -27,13 +27,12 @@ import { invalidateLinksQuery } from '@src/course-libraries/data/apiHooks'; import type { XBlock } from '@src/data/types'; import { useCourseAuthoringContext } from '@src/CourseAuthoringContext'; import { useOutlineSidebarContext } from '../outline-sidebar/OutlineSidebarContext'; -import { useCourseItemData } from '@src/course-outline/data/apiHooks'; +import { courseOutlineQueryKeys, useCourseItemData } from '@src/course-outline/data/apiHooks'; interface UnitCardProps { unit: XBlock; subsection: XBlock; section: XBlock; - onOpenPublishModal: () => void; onOpenConfigureModal: () => void; savingStatus?: RequestStatusType; onOpenDeleteModal: () => void; @@ -57,7 +56,6 @@ const UnitCard = ({ isCustomRelativeDatesActive, index, getPossibleMoves, - onOpenPublishModal, onOpenConfigureModal, onOpenDeleteModal, onDuplicateSubmit, @@ -73,7 +71,7 @@ const UnitCard = ({ const namePrefix = 'unit'; const { copyToClipboard } = useClipboard(); - const { courseId, getUnitUrl, openUnlinkModal } = useCourseAuthoringContext(); + const { courseId, getUnitUrl, openUnlinkModal, openPublishModal } = useCourseAuthoringContext(); const queryClient = useQueryClient(); const { data: section = initialSectionData } = useCourseItemData(initialSectionData.id, initialSectionData); const { data: subsection = initialSubsectionData } = useCourseItemData(initialSubsectionData.id, initialSubsectionData); @@ -186,6 +184,12 @@ const UnitCard = ({ /> ); + /** + Temporary measure to keep the react-query state updated with redux state */ + useEffect(() => { + queryClient.setQueryData(courseOutlineQueryKeys.courseItemId(initialData.id), initialData); + }, [initialData]); + useEffect(() => { // if this items has been newly added, scroll to it. if (currentRef.current && (unit.shouldScroll || isScrolledToElement)) { @@ -240,7 +244,7 @@ const UnitCard = ({ hasChanges={hasChanges} cardId={id} onClickMenuButton={handleClickMenuButton} - onClickPublish={onOpenPublishModal} + onClickPublish={() => openPublishModal({ value: unit, sectionId: section.id })} onClickConfigure={onOpenConfigureModal} onClickDelete={onOpenDeleteModal} onClickUnlink={() => openUnlinkModal({ value: unit, sectionId: section.id })} From 207eba230a54571996a0719a2dbbc3901874dab1 Mon Sep 17 00:00:00 2001 From: Navin Karkera Date: Tue, 20 Jan 2026 20:23:40 +0530 Subject: [PATCH 08/60] fix: rebase issues --- .../outline-sidebar/constants.ts | 28 ------------------- 1 file changed, 28 deletions(-) delete mode 100644 src/course-outline/outline-sidebar/constants.ts diff --git a/src/course-outline/outline-sidebar/constants.ts b/src/course-outline/outline-sidebar/constants.ts deleted file mode 100644 index 39c084b777..0000000000 --- a/src/course-outline/outline-sidebar/constants.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { HelpOutline, Info, Plus } from '@openedx/paragon/icons'; -import type { SidebarPage } from '@src/generic/sidebar'; -import OutlineHelpSidebar from './OutlineHelpSidebar'; -import { InfoSidebar } from './InfoSidebar'; -import messages from './messages'; -import { AddSidebar } from './AddSidebar'; -import type { OutlineSidebarPageKeys } from './OutlineSidebarContext'; - -export type OutlineSidebarPages = Record; - -export const OUTLINE_SIDEBAR_PAGES: OutlineSidebarPages = { - info: { - component: InfoSidebar, - icon: Info, - title: messages.sidebarButtonInfo, - }, - help: { - component: OutlineHelpSidebar, - icon: HelpOutline, - title: messages.sidebarButtonHelp, - }, - add: { - component: AddSidebar, - icon: Plus, - title: messages.sidebarButtonAdd, - hideFromActionMenu: true, - }, -}; From 3ce54296d30f7d74f209b1aef992bc3fa775f60b Mon Sep 17 00:00:00 2001 From: Navin Karkera Date: Wed, 21 Jan 2026 10:18:55 +0530 Subject: [PATCH 09/60] refactor: work on publish --- src/course-outline/card-header/CardHeader.tsx | 2 +- .../outline-sidebar/OutlineSidebarContext.tsx | 2 +- .../outline-sidebar/SectionInfoSidebar.tsx | 13 ++++++++++++- .../outline-sidebar/SubsectionInfoSidebar.tsx | 19 ++++++++++++++++--- 4 files changed, 30 insertions(+), 6 deletions(-) diff --git a/src/course-outline/card-header/CardHeader.tsx b/src/course-outline/card-header/CardHeader.tsx index 77768a6e54..a6d07bf9fb 100644 --- a/src/course-outline/card-header/CardHeader.tsx +++ b/src/course-outline/card-header/CardHeader.tsx @@ -197,7 +197,7 @@ const CardHeader = ({ onBlur={handleEditSubmit} onKeyDown={(e) => { if (e.key === 'Enter') { - handleEditSubmit(titleValue); + handleEditSubmit(); } else if (e.key === ' ') { // Avoid passing propagation to the `SortableItem` in the card, // which executes a `preventDefault`. If propagation is not prevented, diff --git a/src/course-outline/outline-sidebar/OutlineSidebarContext.tsx b/src/course-outline/outline-sidebar/OutlineSidebarContext.tsx index bad336d902..3744f2fa21 100644 --- a/src/course-outline/outline-sidebar/OutlineSidebarContext.tsx +++ b/src/course-outline/outline-sidebar/OutlineSidebarContext.tsx @@ -38,7 +38,7 @@ interface OutlineSidebarContextData { // The container is not necessarily selected to open a selected sidebar. // Example: Align sidebar currentContainerId?: string; - openContainerInfoSidebar: (containerId: string) => void; + openContainerInfoSidebar: (containerId: string, sectionId?: string) => void; } const OutlineSidebarContext = createContext(undefined); diff --git a/src/course-outline/outline-sidebar/SectionInfoSidebar.tsx b/src/course-outline/outline-sidebar/SectionInfoSidebar.tsx index a3f8dffac3..0741ab8b40 100644 --- a/src/course-outline/outline-sidebar/SectionInfoSidebar.tsx +++ b/src/course-outline/outline-sidebar/SectionInfoSidebar.tsx @@ -14,6 +14,7 @@ import { useCourseItemData } from '@src/course-outline/data/apiHooks'; import Loading from '@src/generic/Loading'; import { LibraryReferenceCard } from './LibraryReferenceCard'; import { PublishButon } from './PublishButon'; +import { useCourseAuthoringContext } from '@src/CourseAuthoringContext'; interface Props { sectionId: string; @@ -63,6 +64,16 @@ export const SectionSidebar = ({ sectionId }: Props) => { const intl = useIntl(); const [tab, setTab] = useState<'info' | 'settings'>('info'); const { data: sectionData, isLoading } = useCourseItemData(sectionId); + const { openPublishModal } = useCourseAuthoringContext(); + + const handlePublish = () => { + if (sectionData?.hasChanges) { + openPublishModal({ + value: sectionData, + sectionId: sectionData.id, + }) + } + } if (isLoading) { return ; @@ -74,7 +85,7 @@ export const SectionSidebar = ({ sectionId }: Props) => { title={sectionData?.displayName || ''} icon={SchoolOutline} /> - {}} /> + {sectionData?.hasChanges && } { export const SubsectionSidebar = ({ subsectionId }: Props) => { const intl = useIntl(); const [tab, setTab] = useState<'info' | 'settings'>('info'); - const { data: sectionData, isLoading } = useCourseItemData(subsectionId); + const { data: subsectionData, isLoading } = useCourseItemData(subsectionId); + const { selectedSectionId } = useOutlineSidebarContext(); + const { openPublishModal } = useCourseAuthoringContext(); + + const handlePublish = () => { + if (selectedSectionId && subsectionData?.hasChanges) { + openPublishModal({ + value: subsectionData, + sectionId: selectedSectionId, + }) + } + } if (isLoading) { return ; @@ -71,10 +84,10 @@ export const SubsectionSidebar = ({ subsectionId }: Props) => { return ( <> - {}} /> + {subsectionData?.hasChanges && } Date: Wed, 21 Jan 2026 20:51:25 +0530 Subject: [PATCH 10/60] refactor: current item tracking --- src/CourseAuthoringContext.tsx | 15 +++++- src/course-outline/CourseOutline.tsx | 4 +- src/course-outline/card-header/CardHeader.tsx | 1 - src/course-outline/data/selectors.ts | 3 -- src/course-outline/data/slice.ts | 15 ------ src/course-outline/data/types.ts | 3 -- .../highlights-modal/HighlightsModal.jsx | 6 +-- src/course-outline/hooks.jsx | 50 ++++++++----------- .../section-card/SectionCard.tsx | 9 ++-- .../subsection-card/SubsectionCard.tsx | 11 ++-- src/course-outline/unit-card/UnitCard.tsx | 11 ++-- .../configure-modal/ConfigureModal.jsx | 2 +- 12 files changed, 59 insertions(+), 71 deletions(-) diff --git a/src/CourseAuthoringContext.tsx b/src/CourseAuthoringContext.tsx index 17359a8888..ee9c412e98 100644 --- a/src/CourseAuthoringContext.tsx +++ b/src/CourseAuthoringContext.tsx @@ -1,5 +1,5 @@ import { getConfig } from '@edx/frontend-platform'; -import { createContext, useCallback, useContext, useMemo } from 'react'; +import { createContext, useCallback, useContext, useMemo, useState } from 'react'; import { getAuthenticatedUser } from '@edx/frontend-platform/auth'; import { useCreateCourseBlock } from '@src/course-outline/data/apiHooks'; import { getCourseItem } from '@src/course-outline/data/api'; @@ -41,6 +41,8 @@ export type CourseAuthoringContextData = { currentPublishModalData?: ModalState; openPublishModal: (value: ModalState) => void; closePublishModal: () => void; + currentSelection?: SelectionState; + setCurrentSelection: (value?: SelectionState) => void; }; /** @@ -57,6 +59,12 @@ type CourseAuthoringProviderProps = { courseId: string; }; +type SelectionState = { + current: XBlock; + section?: XBlock; + subsection?: XBlock; +} + export const CourseAuthoringProvider = ({ children, courseId, @@ -70,6 +78,7 @@ export const CourseAuthoringProvider = ({ const { id: courseUsageKey } = courseStructure || {}; const [isUnlinkModalOpen, currentUnlinkModalData, openUnlinkModal, closeUnlinkModal] = useToggleWithValue(); const [isPublishModalOpen, currentPublishModalData, openPublishModal, closePublishModal] = useToggleWithValue(); + const [currentSelection, setCurrentSelection] = useState(); const getUnitUrl = (locator: string) => { if (getConfig().ENABLE_UNIT_PAGE === 'true' && waffleFlags.useNewUnitPage) { @@ -159,6 +168,8 @@ export const CourseAuthoringProvider = ({ currentPublishModalData, openPublishModal, closePublishModal, + currentSelection, + setCurrentSelection, }), [ courseId, courseUsageKey, @@ -179,6 +190,8 @@ export const CourseAuthoringProvider = ({ currentPublishModalData, openPublishModal, closePublishModal, + currentSelection, + setCurrentSelection, ]); return ( diff --git a/src/course-outline/CourseOutline.tsx b/src/course-outline/CourseOutline.tsx index f4e2742e88..33ff8f7561 100644 --- a/src/course-outline/CourseOutline.tsx +++ b/src/course-outline/CourseOutline.tsx @@ -37,7 +37,6 @@ import { useCourseAuthoringContext } from '@src/CourseAuthoringContext'; import LegacyLibContentBlockAlert from '@src/course-libraries/LegacyLibContentBlockAlert'; import { ContainerType } from '@src/generic/key-utils'; import { - getCurrentItem, getProctoredExamsFlag, getTimedExamsFlag, } from './data/selectors'; @@ -77,6 +76,7 @@ const CourseOutline = () => { isUnlinkModalOpen, closeUnlinkModal, handleUnlinkItemSubmit, + currentSelection, } = useCourseAuthoringContext(); const { @@ -163,7 +163,7 @@ const CourseOutline = () => { title: processingNotificationTitle, } = useSelector(getProcessingNotification); - const currentItemData = useSelector(getCurrentItem); + const currentItemData = currentSelection?.current; const itemCategory = currentItemData?.category; const itemCategoryName = COURSE_BLOCK_NAMES[itemCategory]?.name.toLowerCase(); diff --git a/src/course-outline/card-header/CardHeader.tsx b/src/course-outline/card-header/CardHeader.tsx index a6d07bf9fb..fa50fd3c57 100644 --- a/src/course-outline/card-header/CardHeader.tsx +++ b/src/course-outline/card-header/CardHeader.tsx @@ -161,7 +161,6 @@ const CardHeader = ({ const editMutation = useUpdateCourseBlockName(courseId); const handleEditSubmit = useCallback(async () => { if (title !== titleValue) { - // both itemId and sectionId are same await editMutation.mutateAsync({ itemId: cardId, displayName: titleValue, diff --git a/src/course-outline/data/selectors.ts b/src/course-outline/data/selectors.ts index 587badfcc6..b5ab44022b 100644 --- a/src/course-outline/data/selectors.ts +++ b/src/course-outline/data/selectors.ts @@ -3,9 +3,6 @@ export const getLoadingStatus = (state) => state.courseOutline.loadingStatus; export const getStatusBarData = (state) => state.courseOutline.statusBarData; export const getSavingStatus = (state) => state.courseOutline.savingStatus; export const getSectionsList = (state) => state.courseOutline.sectionsList; -export const getCurrentItem = (state) => state.courseOutline.currentItem; -export const getCurrentSection = (state) => state.courseOutline.currentSection; -export const getCurrentSubsection = (state) => state.courseOutline.currentSubsection; export const getCourseActions = (state) => state.courseOutline.actions; export const getCustomRelativeDatesActiveFlag = (state) => state.courseOutline.isCustomRelativeDatesActive; export const getProctoredExamsFlag = (state) => state.courseOutline.enableProctoredExams; diff --git a/src/course-outline/data/slice.ts b/src/course-outline/data/slice.ts index c56d66470d..fb90616304 100644 --- a/src/course-outline/data/slice.ts +++ b/src/course-outline/data/slice.ts @@ -37,9 +37,6 @@ const initialState = { }, sectionsList: [], isCustomRelativeDatesActive: false, - currentSection: {}, - currentSubsection: {}, - currentItem: {}, actions: { deletable: true, unlinkable: false, @@ -125,21 +122,12 @@ const slice = createSlice({ updateSectionList: (state: CourseOutlineState, { payload }) => { state.sectionsList = state.sectionsList.map((section) => (section.id in payload ? payload[section.id] : section)); }, - setCurrentItem: (state: CourseOutlineState, { payload }) => { - state.currentItem = payload; - }, reorderSectionList: (state: CourseOutlineState, { payload }) => { const sectionsList = [...state.sectionsList]; sectionsList.sort((a, b) => payload.indexOf(a.id) - payload.indexOf(b.id)); state.sectionsList = [...sectionsList]; }, - setCurrentSection: (state: CourseOutlineState, { payload }) => { - state.currentSection = payload; - }, - setCurrentSubsection: (state: CourseOutlineState, { payload }) => { - state.currentSubsection = payload; - }, addSection: (state: CourseOutlineState, { payload }) => { state.sectionsList = [ ...state.sectionsList, @@ -233,9 +221,6 @@ export const { updateCourseLaunchQueryStatus, updateSavingStatus, updateSectionList, - setCurrentItem, - setCurrentSection, - setCurrentSubsection, deleteSection, deleteSubsection, deleteUnit, diff --git a/src/course-outline/data/types.ts b/src/course-outline/data/types.ts index 4199ea85f4..b141b7d792 100644 --- a/src/course-outline/data/types.ts +++ b/src/course-outline/data/types.ts @@ -71,9 +71,6 @@ export interface CourseOutlineState { statusBarData: CourseOutlineStatusBar; sectionsList: Array; isCustomRelativeDatesActive: boolean; - currentSection: XBlock | {}; - currentSubsection: XBlock | {}; - currentItem: XBlock | {}; actions: XBlockActions; enableProctoredExams: boolean; enableTimedExams: boolean; diff --git a/src/course-outline/highlights-modal/HighlightsModal.jsx b/src/course-outline/highlights-modal/HighlightsModal.jsx index 91d806f0b2..c540d78362 100644 --- a/src/course-outline/highlights-modal/HighlightsModal.jsx +++ b/src/course-outline/highlights-modal/HighlightsModal.jsx @@ -8,14 +8,13 @@ import { Hyperlink, } from '@openedx/paragon'; import { Formik } from 'formik'; -import { useSelector } from 'react-redux'; import { useHelpUrls } from '../../help-urls/hooks'; import FormikControl from '../../generic/FormikControl'; -import { getCurrentSection } from '../data/selectors'; import { HIGHLIGHTS_FIELD_MAX_LENGTH } from '../constants'; import { getHighlightsFormValues } from '../utils'; import messages from './messages'; +import { useCourseAuthoringContext } from '@src/CourseAuthoringContext'; const HighlightsModal = ({ isOpen, @@ -23,7 +22,8 @@ const HighlightsModal = ({ onSubmit, }) => { const intl = useIntl(); - const { highlights = [], displayName } = useSelector(getCurrentSection); + const { currentSelection } = useCourseAuthoringContext(); + const { highlights = [], displayName } = currentSelection?.current || {}; const initialFormValues = getHighlightsFormValues(highlights); const { diff --git a/src/course-outline/hooks.jsx b/src/course-outline/hooks.jsx index 2984beb6e9..7ba08e41f2 100644 --- a/src/course-outline/hooks.jsx +++ b/src/course-outline/hooks.jsx @@ -11,8 +11,6 @@ import { useCourseAuthoringContext } from '@src/CourseAuthoringContext'; import { ContainerType } from '@src/generic/key-utils'; import { COURSE_BLOCK_NAMES } from './constants'; import { - setCurrentItem, - setCurrentSection, resetScrollField, updateSavingStatus, } from './data/slice'; @@ -23,9 +21,6 @@ import { getStatusBarData, getSectionsList, getCourseActions, - getCurrentItem, - getCurrentSection, - getCurrentSubsection, getCustomRelativeDatesActiveFlag, getErrors, getCreatedOn, @@ -58,7 +53,7 @@ import { useOutlineSidebarContext } from '@src/course-outline/outline-sidebar/Ou const useCourseOutline = ({ courseId }) => { const dispatch = useDispatch(); - const { handleAddSection } = useCourseAuthoringContext(); + const { handleAddSection, setCurrentSelection, currentSelection } = useCourseAuthoringContext(); const { selectedContainerId, clearSelection } = useOutlineSidebarContext(); const { @@ -80,9 +75,6 @@ const useCourseOutline = ({ courseId }) => { const savingStatus = useSelector(getSavingStatus); const courseActions = useSelector(getCourseActions); const sectionsList = useSelector(getSectionsList); - const currentItem = useSelector(getCurrentItem); - const currentSection = useSelector(getCurrentSection); - const currentSubsection = useSelector(getCurrentSubsection); const isCustomRelativeDatesActive = useSelector(getCustomRelativeDatesActiveFlag); const genericSavingStatus = useSelector(getGenericSavingStatus); const errors = useSelector(getErrors); @@ -138,34 +130,36 @@ const useCourseOutline = ({ courseId }) => { }; const handleOpenHighlightsModal = (section) => { - dispatch(setCurrentItem(section)); - dispatch(setCurrentSection(section)); + setCurrentSelection({ + current: section, + section, + }); openHighlightsModal(); }; const handleHighlightsFormSubmit = (highlights) => { const dataToSend = Object.values(highlights).filter(Boolean); - dispatch(updateCourseSectionHighlightsQuery(currentItem.id, dataToSend)); + dispatch(updateCourseSectionHighlightsQuery(currentSelection?.current.id, dataToSend)); closeHighlightsModal(); }; const handleConfigureModalClose = () => { closeConfigureModal(); - // reset the currentItem so the ConfigureModal's state is also reset - dispatch(setCurrentItem({})); + // reset the currentSelection?.current so the ConfigureModal's state is also reset + setCurrentSelection(undefined) }; const handleConfigureItemSubmit = (...arg) => { - switch (currentItem.category) { + switch (currentSelection?.current.category) { case COURSE_BLOCK_NAMES.chapter.id: - dispatch(configureCourseSectionQuery(currentSection.id, ...arg)); + dispatch(configureCourseSectionQuery(currentSelection?.section.id, ...arg)); break; case COURSE_BLOCK_NAMES.sequential.id: - dispatch(configureCourseSubsectionQuery(currentItem.id, currentSection.id, ...arg)); + dispatch(configureCourseSubsectionQuery(currentSelection?.current.id, currentSelection?.section.id, ...arg)); break; case COURSE_BLOCK_NAMES.vertical.id: - dispatch(configureCourseUnitQuery(currentItem.id, currentSection.id, ...arg)); + dispatch(configureCourseUnitQuery(currentSelection?.current.id, currentSelection?.section.id, ...arg)); break; default: return; @@ -174,39 +168,39 @@ const useCourseOutline = ({ courseId }) => { }; const handleDeleteItemSubmit = () => { - switch (currentItem.category) { + switch (currentSelection?.current.category) { case COURSE_BLOCK_NAMES.chapter.id: - dispatch(deleteCourseSectionQuery(currentItem.id)); + dispatch(deleteCourseSectionQuery(currentSelection?.current.id)); break; case COURSE_BLOCK_NAMES.sequential.id: - dispatch(deleteCourseSubsectionQuery(currentItem.id, currentSection.id)); + dispatch(deleteCourseSubsectionQuery(currentSelection?.current.id, currentSelection?.section.id)); break; case COURSE_BLOCK_NAMES.vertical.id: dispatch(deleteCourseUnitQuery( - currentItem.id, - currentSubsection.id, - currentSection.id, + currentSelection?.current.id, + currentSelection?.subsection.id, + currentSelection?.section.id, )); break; default: return; } - if (selectedContainerId === currentItem.id) { + if (selectedContainerId === currentSelection?.current.id) { clearSelection(); } closeDeleteModal(); }; const handleDuplicateSectionSubmit = () => { - dispatch(duplicateSectionQuery(currentSection.id, courseStructure.id)); + dispatch(duplicateSectionQuery(currentSelection?.section.id, courseStructure.id)); }; const handleDuplicateSubsectionSubmit = () => { - dispatch(duplicateSubsectionQuery(currentSubsection.id, currentSection.id)); + dispatch(duplicateSubsectionQuery(currentSelection?.subsection.id, currentSelection?.section.id)); }; const handleDuplicateUnitSubmit = () => { - dispatch(duplicateUnitQuery(currentItem.id, currentSubsection.id, currentSection.id)); + dispatch(duplicateUnitQuery(currentSelection?.current.id, currentSelection?.subsection.id, currentSelection?.section.id)); }; const handleVideoSharingOptionChange = (value) => { diff --git a/src/course-outline/section-card/SectionCard.tsx b/src/course-outline/section-card/SectionCard.tsx index cde2010c0f..07215648dd 100644 --- a/src/course-outline/section-card/SectionCard.tsx +++ b/src/course-outline/section-card/SectionCard.tsx @@ -9,7 +9,6 @@ import { useSearchParams } from 'react-router-dom'; import classNames from 'classnames'; import { useQueryClient } from '@tanstack/react-query'; -import { setCurrentItem, setCurrentSection } from '@src/course-outline/data/slice'; import CardHeader from '@src/course-outline/card-header/CardHeader'; import SortableItem from '@src/course-outline/drag-helper/SortableItem'; import { DragContext } from '@src/course-outline/drag-helper/DragContextProvider'; @@ -65,7 +64,7 @@ const SectionCard = ({ const { selectedContainerId, openContainerInfoSidebar } = useOutlineSidebarContext(); const [searchParams] = useSearchParams(); const locatorId = searchParams.get('show'); - const { courseId, openUnlinkModal, openPublishModal } = useCourseAuthoringContext(); + const { courseId, openUnlinkModal, openPublishModal, setCurrentSelection } = useCourseAuthoringContext(); const queryClient = useQueryClient(); // Set initialData state from course outline and subsequently depend on its own state const { data: section = initialData } = useCourseItemData(initialData.id, initialData); @@ -188,8 +187,10 @@ const SectionCard = ({ }; const handleClickMenuButton = () => { - dispatch(setCurrentItem(section)); - dispatch(setCurrentSection(section)); + setCurrentSelection({ + current: section, + section, + }); }; const handleOpenHighlightsModal = () => { diff --git a/src/course-outline/subsection-card/SubsectionCard.tsx b/src/course-outline/subsection-card/SubsectionCard.tsx index fe367b4d8f..3f5588c801 100644 --- a/src/course-outline/subsection-card/SubsectionCard.tsx +++ b/src/course-outline/subsection-card/SubsectionCard.tsx @@ -10,7 +10,6 @@ import classNames from 'classnames'; import { isEmpty } from 'lodash'; import CourseOutlineSubsectionCardExtraActionsSlot from '@src/plugin-slots/CourseOutlineSubsectionCardExtraActionsSlot'; -import { setCurrentItem, setCurrentSection, setCurrentSubsection } from '@src/course-outline/data/slice'; import CardHeader from '@src/course-outline/card-header/CardHeader'; import SortableItem from '@src/course-outline/drag-helper/SortableItem'; import { DragContext } from '@src/course-outline/drag-helper/DragContextProvider'; @@ -73,7 +72,7 @@ const SubsectionCard = ({ const [isSyncModalOpen, openSyncModal, closeSyncModal] = useToggle(false); const namePrefix = 'subsection'; const { sharedClipboardData, showPasteUnit } = useClipboard(); - const { courseId, openUnlinkModal, openPublishModal } = useCourseAuthoringContext(); + const { courseId, openUnlinkModal, openPublishModal, setCurrentSelection } = useCourseAuthoringContext(); const queryClient = useQueryClient(); // Set initialData state from course outline and subsequently depend on its own state const { data: section = initialSectionData } = useCourseItemData(initialSectionData.id, initialSectionData); @@ -150,9 +149,11 @@ const SubsectionCard = ({ }; const handleClickMenuButton = () => { - dispatch(setCurrentSection(section)); - dispatch(setCurrentSubsection(subsection)); - dispatch(setCurrentItem(subsection)); + setCurrentSelection({ + current: subsection, + subsection, + section, + }); }; const handleOnPostChangeSync = useCallback(() => { diff --git a/src/course-outline/unit-card/UnitCard.tsx b/src/course-outline/unit-card/UnitCard.tsx index bb229634f1..145049c3ad 100644 --- a/src/course-outline/unit-card/UnitCard.tsx +++ b/src/course-outline/unit-card/UnitCard.tsx @@ -12,7 +12,6 @@ import { useSearchParams } from 'react-router-dom'; import { useQueryClient } from '@tanstack/react-query'; import CourseOutlineUnitCardExtraActionsSlot from '@src/plugin-slots/CourseOutlineUnitCardExtraActionsSlot'; -import { setCurrentItem, setCurrentSection, setCurrentSubsection } from '@src/course-outline/data/slice'; import { fetchCourseSectionQuery } from '@src/course-outline/data/thunk'; import { RequestStatusType } from '@src/data/constants'; import CardHeader from '@src/course-outline/card-header/CardHeader'; @@ -71,7 +70,7 @@ const UnitCard = ({ const namePrefix = 'unit'; const { copyToClipboard } = useClipboard(); - const { courseId, getUnitUrl, openUnlinkModal, openPublishModal } = useCourseAuthoringContext(); + const { courseId, getUnitUrl, openUnlinkModal, openPublishModal, setCurrentSelection } = useCourseAuthoringContext(); const queryClient = useQueryClient(); const { data: section = initialSectionData } = useCourseItemData(initialSectionData.id, initialSectionData); const { data: subsection = initialSubsectionData } = useCourseItemData(initialSubsectionData.id, initialSubsectionData); @@ -130,9 +129,11 @@ const UnitCard = ({ const borderStyle = getItemStatusBorder(unitStatus); const handleClickMenuButton = () => { - dispatch(setCurrentItem(unit)); - dispatch(setCurrentSection(section)); - dispatch(setCurrentSubsection(subsection)); + setCurrentSelection({ + current: unit, + subsection, + section, + }); }; const handleUnitMoveUp = () => { diff --git a/src/generic/configure-modal/ConfigureModal.jsx b/src/generic/configure-modal/ConfigureModal.jsx index e34dfbcc15..a62b9da8bb 100644 --- a/src/generic/configure-modal/ConfigureModal.jsx +++ b/src/generic/configure-modal/ConfigureModal.jsx @@ -61,7 +61,7 @@ const ConfigureModal = ({ showReviewRules, onlineProctoringRules, discussionEnabled, - } = currentItemData; + } = currentItemData || {}; const getSelectedGroups = () => { if (userPartitionInfo?.selectedPartitionIndex >= 0) { From 895d3004685bd3f82073182dd50fda9aa8d880ba Mon Sep 17 00:00:00 2001 From: Navin Karkera Date: Thu, 22 Jan 2026 10:37:40 +0530 Subject: [PATCH 11/60] refactor: container selection in sidebar --- src/CourseAuthoringContext.tsx | 15 ++++---- src/course-outline/CourseOutline.tsx | 2 +- src/course-outline/card-header/CardHeader.tsx | 23 +++++++++--- src/course-outline/data/apiHooks.ts | 2 -- .../highlights-modal/HighlightsModal.jsx | 2 +- src/course-outline/hooks.jsx | 36 +++++++++---------- .../outline-sidebar/InfoSidebar.tsx | 10 +++--- .../outline-sidebar/LibraryReferenceCard.tsx | 20 +++++------ .../outline-sidebar/OutlineSidebarContext.tsx | 29 ++++++--------- .../outline-sidebar/SubsectionInfoSidebar.tsx | 6 ++-- .../section-card/SectionCard.tsx | 8 ++--- .../subsection-card/SubsectionCard.tsx | 10 +++--- src/course-outline/unit-card/UnitCard.tsx | 10 +++--- src/data/types.ts | 6 ++++ 14 files changed, 94 insertions(+), 85 deletions(-) diff --git a/src/CourseAuthoringContext.tsx b/src/CourseAuthoringContext.tsx index ee9c412e98..8980fb0429 100644 --- a/src/CourseAuthoringContext.tsx +++ b/src/CourseAuthoringContext.tsx @@ -1,7 +1,7 @@ import { getConfig } from '@edx/frontend-platform'; import { createContext, useCallback, useContext, useMemo, useState } from 'react'; import { getAuthenticatedUser } from '@edx/frontend-platform/auth'; -import { useCreateCourseBlock } from '@src/course-outline/data/apiHooks'; +import { courseOutlineQueryKeys, useCreateCourseBlock } from '@src/course-outline/data/apiHooks'; import { getCourseItem } from '@src/course-outline/data/api'; import { useDispatch, useSelector } from 'react-redux'; import { addSection, addSubsection, updateSavingStatus } from '@src/course-outline/data/slice'; @@ -11,9 +11,10 @@ import { RequestStatus, RequestStatusType } from './data/constants'; import { useCourseDetails, useWaffleFlags } from './data/apiHooks'; import { CourseDetailsData } from './data/api'; import { useToggleWithValue } from '@src/hooks'; -import { XBlock } from '@src/data/types'; +import { SelectionState, XBlock } from '@src/data/types'; import { useUnlinkDownstream } from '@src/generic/unlink-modal'; import { fetchCourseSectionQuery } from '@src/course-outline/data/thunk'; +import { useQueryClient } from '@tanstack/react-query'; type ModalState = { value: XBlock; @@ -59,12 +60,6 @@ type CourseAuthoringProviderProps = { courseId: string; }; -type SelectionState = { - current: XBlock; - section?: XBlock; - subsection?: XBlock; -} - export const CourseAuthoringProvider = ({ children, courseId, @@ -79,6 +74,7 @@ export const CourseAuthoringProvider = ({ const [isUnlinkModalOpen, currentUnlinkModalData, openUnlinkModal, closeUnlinkModal] = useToggleWithValue(); const [isPublishModalOpen, currentPublishModalData, openPublishModal, closePublishModal] = useToggleWithValue(); const [currentSelection, setCurrentSelection] = useState(); + const queryClient = useQueryClient(); const getUnitUrl = (locator: string) => { if (getConfig().ENABLE_UNIT_PAGE === 'true' && waffleFlags.useNewUnitPage) { @@ -109,6 +105,9 @@ export const CourseAuthoringProvider = ({ * Open the unit page for a given locator. */ const openUnitPage = (locator: string) => { + queryClient.invalidateQueries({ + queryKey: courseOutlineQueryKeys.courseItemId(currentSelection?.sectionId), + }); const url = getUnitUrl(locator); if (getConfig().ENABLE_UNIT_PAGE === 'true' && waffleFlags.useNewUnitPage) { // instanbul ignore next diff --git a/src/course-outline/CourseOutline.tsx b/src/course-outline/CourseOutline.tsx index 33ff8f7561..550b915771 100644 --- a/src/course-outline/CourseOutline.tsx +++ b/src/course-outline/CourseOutline.tsx @@ -163,7 +163,7 @@ const CourseOutline = () => { title: processingNotificationTitle, } = useSelector(getProcessingNotification); - const currentItemData = currentSelection?.current; + const currentItemData = currentSelection?.currentId; const itemCategory = currentItemData?.category; const itemCategoryName = COURSE_BLOCK_NAMES[itemCategory]?.name.toLowerCase(); diff --git a/src/course-outline/card-header/CardHeader.tsx b/src/course-outline/card-header/CardHeader.tsx index fa50fd3c57..82280c9a3e 100644 --- a/src/course-outline/card-header/CardHeader.tsx +++ b/src/course-outline/card-header/CardHeader.tsx @@ -30,8 +30,9 @@ import { scrollToElement } from '../utils'; import CardStatus from './CardStatus'; import messages from './messages'; import { useOutlineSidebarContext } from '../outline-sidebar/OutlineSidebarContext'; -import { useUpdateCourseBlockName } from '@src/course-outline/data/apiHooks'; +import { courseOutlineQueryKeys, useUpdateCourseBlockName } from '@src/course-outline/data/apiHooks'; import { useCourseAuthoringContext } from '@src/CourseAuthoringContext'; +import { useQueryClient } from '@tanstack/react-query'; interface CardHeaderProps { title: string; @@ -106,6 +107,7 @@ const CardHeader = ({ const cardHeaderRef = useRef(null); const [isLegacyManageTagsDrawerOpen, openLegacyTagsDrawer, closeLegacyTagsDrawer] = useToggle(false); const { setCurrentPageKey } = useOutlineSidebarContext(); + const queryClient = useQueryClient(); const openManageTagsDrawer = useCallback(() => { const showNewSidebar = getConfig().ENABLE_COURSE_OUTLINE_NEW_DESIGN?.toString().toLowerCase() === 'true'; @@ -115,7 +117,7 @@ const CardHeader = ({ openLegacyTagsDrawer(); } }, [setCurrentPageKey, openLegacyTagsDrawer, cardId]); - const { courseId } = useCourseAuthoringContext(); + const { courseId, currentSelection } = useCourseAuthoringContext(); const [isFormOpen, openForm, closeForm] = useToggle(false); // Use studio url as base if proctoringExamConfigurationLink is a relative link @@ -128,6 +130,11 @@ const CardHeader = ({ const { data: contentTagCount } = useContentTagsCount(cardId); + const onEditClick = () => { + onClickMenuButton(); + openForm(); + } + useEffect(() => { const locatorId = searchParams.get('show'); if (!locatorId) { @@ -165,7 +172,15 @@ const CardHeader = ({ itemId: cardId, displayName: titleValue, }, { - onSuccess: closeForm, + onSuccess: () => { + closeForm(); + queryClient.invalidateQueries({ + queryKey: courseOutlineQueryKeys.courseItemId(currentSelection?.sectionId), + }); + queryClient.invalidateQueries({ + queryKey: courseOutlineQueryKeys.courseItemId(currentSelection?.subsectionId), + }); + }, }); } closeForm(); @@ -216,7 +231,7 @@ const CardHeader = ({ alt={intl.formatMessage(messages.altButtonRename)} tooltipContent={
{intl.formatMessage(messages.altButtonRename)}
} iconAs={EditIcon} - onClick={openForm} + onClick={onEditClick} // @ts-ignore disabled={editMutation.isPending} /> diff --git a/src/course-outline/data/apiHooks.ts b/src/course-outline/data/apiHooks.ts index 08180e0590..3f876e4f11 100644 --- a/src/course-outline/data/apiHooks.ts +++ b/src/course-outline/data/apiHooks.ts @@ -41,7 +41,6 @@ export const useCreateCourseBlock = ( return useMutation({ mutationFn: createCourseXblock, onSettled: async (data: { locator: string; }, _err, variables) => { - // FIXME: invalidate section query queryClient.invalidateQueries({ queryKey: courseOutlineQueryKeys.courseItemId(variables.parentLocator) }); callback?.(data.locator, variables.parentLocator); }, @@ -68,7 +67,6 @@ export const useUpdateCourseBlockName = (courseId: string) => { return useMutation({ mutationFn: editItemDisplayName, onSettled: async (_data, _err, variables) => { - // FIXME: invalidate section query queryClient.invalidateQueries({ queryKey: containerComparisonQueryKeys.course(courseId) }); queryClient.invalidateQueries({ queryKey: courseOutlineQueryKeys.courseItemId(variables.itemId) }); }, diff --git a/src/course-outline/highlights-modal/HighlightsModal.jsx b/src/course-outline/highlights-modal/HighlightsModal.jsx index c540d78362..7a80dbe675 100644 --- a/src/course-outline/highlights-modal/HighlightsModal.jsx +++ b/src/course-outline/highlights-modal/HighlightsModal.jsx @@ -23,7 +23,7 @@ const HighlightsModal = ({ }) => { const intl = useIntl(); const { currentSelection } = useCourseAuthoringContext(); - const { highlights = [], displayName } = currentSelection?.current || {}; + const { highlights = [], displayName } = currentSelection?.currentId || {}; const initialFormValues = getHighlightsFormValues(highlights); const { diff --git a/src/course-outline/hooks.jsx b/src/course-outline/hooks.jsx index 7ba08e41f2..7d9c1b622b 100644 --- a/src/course-outline/hooks.jsx +++ b/src/course-outline/hooks.jsx @@ -54,7 +54,7 @@ import { useOutlineSidebarContext } from '@src/course-outline/outline-sidebar/Ou const useCourseOutline = ({ courseId }) => { const dispatch = useDispatch(); const { handleAddSection, setCurrentSelection, currentSelection } = useCourseAuthoringContext(); - const { selectedContainerId, clearSelection } = useOutlineSidebarContext(); + const { selectedContainerState, clearSelection } = useOutlineSidebarContext(); const { reindexLink, @@ -131,15 +131,15 @@ const useCourseOutline = ({ courseId }) => { const handleOpenHighlightsModal = (section) => { setCurrentSelection({ - current: section, - section, + currentId: section.id, + sectionId: section.id, }); openHighlightsModal(); }; const handleHighlightsFormSubmit = (highlights) => { const dataToSend = Object.values(highlights).filter(Boolean); - dispatch(updateCourseSectionHighlightsQuery(currentSelection?.current.id, dataToSend)); + dispatch(updateCourseSectionHighlightsQuery(currentSelection?.currentId.id, dataToSend)); closeHighlightsModal(); }; @@ -151,15 +151,15 @@ const useCourseOutline = ({ courseId }) => { }; const handleConfigureItemSubmit = (...arg) => { - switch (currentSelection?.current.category) { + switch (currentSelection?.currentId.category) { case COURSE_BLOCK_NAMES.chapter.id: - dispatch(configureCourseSectionQuery(currentSelection?.section.id, ...arg)); + dispatch(configureCourseSectionQuery(currentSelection?.sectionId.id, ...arg)); break; case COURSE_BLOCK_NAMES.sequential.id: - dispatch(configureCourseSubsectionQuery(currentSelection?.current.id, currentSelection?.section.id, ...arg)); + dispatch(configureCourseSubsectionQuery(currentSelection?.currentId.id, currentSelection?.sectionId.id, ...arg)); break; case COURSE_BLOCK_NAMES.vertical.id: - dispatch(configureCourseUnitQuery(currentSelection?.current.id, currentSelection?.section.id, ...arg)); + dispatch(configureCourseUnitQuery(currentSelection?.currentId.id, currentSelection?.sectionId.id, ...arg)); break; default: return; @@ -168,39 +168,39 @@ const useCourseOutline = ({ courseId }) => { }; const handleDeleteItemSubmit = () => { - switch (currentSelection?.current.category) { + switch (currentSelection?.currentId.category) { case COURSE_BLOCK_NAMES.chapter.id: - dispatch(deleteCourseSectionQuery(currentSelection?.current.id)); + dispatch(deleteCourseSectionQuery(currentSelection?.currentId.id)); break; case COURSE_BLOCK_NAMES.sequential.id: - dispatch(deleteCourseSubsectionQuery(currentSelection?.current.id, currentSelection?.section.id)); + dispatch(deleteCourseSubsectionQuery(currentSelection?.currentId.id, currentSelection?.sectionId.id)); break; case COURSE_BLOCK_NAMES.vertical.id: dispatch(deleteCourseUnitQuery( - currentSelection?.current.id, - currentSelection?.subsection.id, - currentSelection?.section.id, + currentSelection?.currentId.id, + currentSelection?.subsectionId.id, + currentSelection?.sectionId.id, )); break; default: return; } - if (selectedContainerId === currentSelection?.current.id) { + if (selectedContainerState.currentId === currentSelection?.currentId.id) { clearSelection(); } closeDeleteModal(); }; const handleDuplicateSectionSubmit = () => { - dispatch(duplicateSectionQuery(currentSelection?.section.id, courseStructure.id)); + dispatch(duplicateSectionQuery(currentSelection?.sectionId.id, courseStructure.id)); }; const handleDuplicateSubsectionSubmit = () => { - dispatch(duplicateSubsectionQuery(currentSelection?.subsection.id, currentSelection?.section.id)); + dispatch(duplicateSubsectionQuery(currentSelection?.subsectionId.id, currentSelection?.sectionId.id)); }; const handleDuplicateUnitSubmit = () => { - dispatch(duplicateUnitQuery(currentSelection?.current.id, currentSelection?.subsection.id, currentSelection?.section.id)); + dispatch(duplicateUnitQuery(currentSelection?.currentId.id, currentSelection?.subsectionId.id, currentSelection?.sectionId.id)); }; const handleVideoSharingOptionChange = (value) => { diff --git a/src/course-outline/outline-sidebar/InfoSidebar.tsx b/src/course-outline/outline-sidebar/InfoSidebar.tsx index bf24b7e093..980ecb06e9 100644 --- a/src/course-outline/outline-sidebar/InfoSidebar.tsx +++ b/src/course-outline/outline-sidebar/InfoSidebar.tsx @@ -5,21 +5,21 @@ import { SectionSidebar } from "./SectionInfoSidebar"; import { SubsectionSidebar } from "@src/course-outline/outline-sidebar/SubsectionInfoSidebar"; export const InfoSidebar = () => { - const { selectedContainerId } = useOutlineSidebarContext(); - if (!selectedContainerId) { + const { selectedContainerState } = useOutlineSidebarContext(); + if (!selectedContainerState) { return ( ) } - const itemType = getBlockType(selectedContainerId); + const itemType = getBlockType(selectedContainerState.currentId); switch (itemType) { case ContainerType.Chapter: case ContainerType.Section: - return + return case ContainerType.Sequential: case ContainerType.Subsection: - return + return case ContainerType.Vertical: case ContainerType.Unit: return
Unit sidebar
; diff --git a/src/course-outline/outline-sidebar/LibraryReferenceCard.tsx b/src/course-outline/outline-sidebar/LibraryReferenceCard.tsx index 05f992d223..fbaf335102 100644 --- a/src/course-outline/outline-sidebar/LibraryReferenceCard.tsx +++ b/src/course-outline/outline-sidebar/LibraryReferenceCard.tsx @@ -23,16 +23,16 @@ interface SubProps { const HasTopParentTextAndButton = ({ blockData, displayName, openSyncModal }: SubProps) => { const { upstreamInfo } = blockData; - const { selectedSectionId } = useOutlineSidebarContext(); + const { selectedContainerState } = useOutlineSidebarContext(); const { openContainerInfoSidebar } = useOutlineSidebarContext(); const { openUnlinkModal } = useCourseAuthoringContext(); const { data: parentData, isPending } = useCourseItemData(upstreamInfo?.topLevelParentKey); const handleUnlinkClick = () => { - if (!selectedSectionId || !parentData) { + if (!selectedContainerState?.sectionId || !parentData) { return; } - openUnlinkModal({ value: parentData, sectionId: selectedSectionId }); + openUnlinkModal({ value: parentData, sectionId: selectedContainerState.sectionId }); }; const handleSyncClick = () => { @@ -99,17 +99,17 @@ const HasTopParentTextAndButton = ({ blockData, displayName, openSyncModal }: Su const TopLevelTextAndButton = ({ blockData, displayName, openSyncModal }: SubProps) => { const { upstreamInfo } = blockData; - const { selectedSectionId } = useOutlineSidebarContext(); + const { selectedContainerState } = useOutlineSidebarContext(); const { openUnlinkModal } = useCourseAuthoringContext(); const messageValues = { name: displayName, } const handleUnlinkClick = () => { - if (!selectedSectionId) { + if (!selectedContainerState?.sectionId) { return; } - openUnlinkModal({ value: blockData, sectionId: selectedSectionId }); + openUnlinkModal({ value: blockData, sectionId: selectedContainerState.sectionId }); }; const handleSyncClick = () => { @@ -161,7 +161,7 @@ interface Props { export const LibraryReferenceCard = ({ itemId }: Props) => { const { data: itemData, isPending } = useCourseItemData(itemId); - const { selectedSectionId } = useOutlineSidebarContext(); + const { selectedContainerState } = useOutlineSidebarContext(); const { courseId } = useCourseAuthoringContext(); const [isSyncModalOpen, syncModalData, openSyncModal, closeSyncModal] = useToggleWithValue(); const dispatch = useDispatch(); @@ -183,8 +183,8 @@ export const LibraryReferenceCard = ({ itemId }: Props) => { }, [syncModalData]); const handleOnPostChangeSync = useCallback(() => { - if (selectedSectionId) { - dispatch(fetchCourseSectionQuery([selectedSectionId])); + if (selectedContainerState?.sectionId) { + dispatch(fetchCourseSectionQuery([selectedContainerState.sectionId])); } if (courseId) { invalidateLinksQuery(queryClient, courseId); @@ -192,7 +192,7 @@ export const LibraryReferenceCard = ({ itemId }: Props) => { queryKey: courseOutlineQueryKeys.course(courseId), }); } - }, [dispatch, selectedSectionId, queryClient, courseId]); + }, [dispatch, selectedContainerState, queryClient, courseId]); if (!itemData?.upstreamInfo?.upstreamRef) { return null; diff --git a/src/course-outline/outline-sidebar/OutlineSidebarContext.tsx b/src/course-outline/outline-sidebar/OutlineSidebarContext.tsx index 3744f2fa21..4139d4f7fa 100644 --- a/src/course-outline/outline-sidebar/OutlineSidebarContext.tsx +++ b/src/course-outline/outline-sidebar/OutlineSidebarContext.tsx @@ -2,7 +2,6 @@ import { createContext, useCallback, useContext, - useEffect, useMemo, useState, } from 'react'; @@ -10,6 +9,7 @@ import { useToggle } from '@openedx/paragon'; import { useEscapeClick, useStateWithUrlSearchParam } from '@src/hooks'; import { isOutlineNewDesignEnabled } from '../utils'; +import { SelectionState } from '@src/data/types'; export type OutlineSidebarPageKeys = 'help' | 'info' | 'add' | 'align'; export type OutlineFlowType = 'use-section' | 'use-subsection' | 'use-unit' | null; @@ -32,8 +32,7 @@ interface OutlineSidebarContextData { isOpen: boolean; open: () => void; toggle: () => void; - selectedContainerId?: string; - selectedSectionId?: string; + selectedContainerState?: SelectionState; // The Id of the container used in the current sidebar page // The container is not necessarily selected to open a selected sidebar. // Example: Align sidebar @@ -54,8 +53,7 @@ export const OutlineSidebarProvider = ({ children }: { children?: React.ReactNod const [currentFlow, setCurrentFlow] = useState(null); const [isOpen, open, , toggle] = useToggle(true); - const [selectedSectionId, setSelectedSectionId] = useState(); - const [selectedContainerId, setSelectedContainerId] = useState(); + const [selectedContainerState, setSelectedContainerState] = useState(); /** * Stops current add content flow. @@ -74,18 +72,14 @@ export const OutlineSidebarProvider = ({ children }: { children?: React.ReactNod const openContainerInfoSidebar = useCallback((containerId: string, sectionId?: string) => { if (isOutlineNewDesignEnabled()) { - setSelectedContainerId(containerId); - if (sectionId) { - setSelectedSectionId(sectionId); - } + setSelectedContainerState({ currentId: containerId, sectionId }); setCurrentPageKey('info'); } - }, [setSelectedContainerId, setSelectedSectionId]); + }, [setSelectedContainerState, setCurrentPageKey]); const clearSelection = useCallback(() => { - setSelectedSectionId(undefined); - setSelectedContainerId(undefined); - }, [setSelectedSectionId, selectedContainerId]); + setSelectedContainerState(undefined); + }, [selectedContainerState]); /** * Starts add content flow. @@ -100,8 +94,7 @@ export const OutlineSidebarProvider = ({ children }: { children?: React.ReactNod useEscapeClick({ onEscape: () => { stopCurrentFlow(); - setSelectedContainerId(undefined); - setSelectedSectionId(undefined); + setSelectedContainerState(undefined); }, dependency: [stopCurrentFlow], }); @@ -116,9 +109,8 @@ export const OutlineSidebarProvider = ({ children }: { children?: React.ReactNod isOpen, open, toggle, - selectedContainerId, + selectedContainerState, currentContainerId, - selectedSectionId, openContainerInfoSidebar, clearSelection, }), @@ -131,9 +123,8 @@ export const OutlineSidebarProvider = ({ children }: { children?: React.ReactNod isOpen, open, toggle, - selectedContainerId, + selectedContainerState, currentContainerId, - selectedSectionId, openContainerInfoSidebar, clearSelection, ], diff --git a/src/course-outline/outline-sidebar/SubsectionInfoSidebar.tsx b/src/course-outline/outline-sidebar/SubsectionInfoSidebar.tsx index d021b62486..38fb609a2a 100644 --- a/src/course-outline/outline-sidebar/SubsectionInfoSidebar.tsx +++ b/src/course-outline/outline-sidebar/SubsectionInfoSidebar.tsx @@ -65,14 +65,14 @@ export const SubsectionSidebar = ({ subsectionId }: Props) => { const intl = useIntl(); const [tab, setTab] = useState<'info' | 'settings'>('info'); const { data: subsectionData, isLoading } = useCourseItemData(subsectionId); - const { selectedSectionId } = useOutlineSidebarContext(); + const { selectedContainerState } = useOutlineSidebarContext(); const { openPublishModal } = useCourseAuthoringContext(); const handlePublish = () => { - if (selectedSectionId && subsectionData?.hasChanges) { + if (selectedContainerState?.sectionId && subsectionData?.hasChanges) { openPublishModal({ value: subsectionData, - sectionId: selectedSectionId, + sectionId: selectedContainerState?.sectionId, }) } } diff --git a/src/course-outline/section-card/SectionCard.tsx b/src/course-outline/section-card/SectionCard.tsx index 07215648dd..2d1a8aea7b 100644 --- a/src/course-outline/section-card/SectionCard.tsx +++ b/src/course-outline/section-card/SectionCard.tsx @@ -61,7 +61,7 @@ const SectionCard = ({ const currentRef = useRef(null); const dispatch = useDispatch(); const { activeId, overId } = useContext(DragContext); - const { selectedContainerId, openContainerInfoSidebar } = useOutlineSidebarContext(); + const { selectedContainerState, openContainerInfoSidebar } = useOutlineSidebarContext(); const [searchParams] = useSearchParams(); const locatorId = searchParams.get('show'); const { courseId, openUnlinkModal, openPublishModal, setCurrentSelection } = useCourseAuthoringContext(); @@ -188,8 +188,8 @@ const SectionCard = ({ const handleClickMenuButton = () => { setCurrentSelection({ - current: section, - section, + currentId: section.id, + sectionId: section.id, }); }; @@ -252,7 +252,7 @@ const SectionCard = ({ 'section-card', { highlight: isScrolledToElement, - 'outline-card-selected': section.id === selectedContainerId, + 'outline-card-selected': section.id === selectedContainerState?.currentId, }, )} data-testid="section-card" diff --git a/src/course-outline/subsection-card/SubsectionCard.tsx b/src/course-outline/subsection-card/SubsectionCard.tsx index 3f5588c801..514419f99c 100644 --- a/src/course-outline/subsection-card/SubsectionCard.tsx +++ b/src/course-outline/subsection-card/SubsectionCard.tsx @@ -66,7 +66,7 @@ const SubsectionCard = ({ const intl = useIntl(); const dispatch = useDispatch(); const { activeId, overId } = useContext(DragContext); - const { selectedContainerId, openContainerInfoSidebar } = useOutlineSidebarContext(); + const { selectedContainerState, openContainerInfoSidebar } = useOutlineSidebarContext(); const [searchParams] = useSearchParams(); const locatorId = searchParams.get('show'); const [isSyncModalOpen, openSyncModal, closeSyncModal] = useToggle(false); @@ -150,9 +150,9 @@ const SubsectionCard = ({ const handleClickMenuButton = () => { setCurrentSelection({ - current: subsection, - subsection, - section, + currentId: subsection.id, + subsectionId: subsection.id, + sectionId: section.id, }); }; @@ -258,7 +258,7 @@ const SubsectionCard = ({ 'subsection-card', { highlight: isScrolledToElement, - 'outline-card-selected': subsection.id === selectedContainerId, + 'outline-card-selected': subsection.id === selectedContainerState?.currentId, }, )} data-testid="subsection-card" diff --git a/src/course-outline/unit-card/UnitCard.tsx b/src/course-outline/unit-card/UnitCard.tsx index 145049c3ad..8ad331476a 100644 --- a/src/course-outline/unit-card/UnitCard.tsx +++ b/src/course-outline/unit-card/UnitCard.tsx @@ -64,7 +64,7 @@ const UnitCard = ({ const currentRef = useRef(null); const dispatch = useDispatch(); const [searchParams] = useSearchParams(); - const { selectedContainerId, openContainerInfoSidebar } = useOutlineSidebarContext(); + const { selectedContainerState, openContainerInfoSidebar } = useOutlineSidebarContext(); const locatorId = searchParams.get('show'); const [isSyncModalOpen, openSyncModal, closeSyncModal] = useToggle(false); const namePrefix = 'unit'; @@ -130,9 +130,9 @@ const UnitCard = ({ const handleClickMenuButton = () => { setCurrentSelection({ - current: unit, - subsection, - section, + currentId: unit.id, + subsectionId: subsection.id, + sectionId: section.id, }); }; @@ -233,7 +233,7 @@ const UnitCard = ({ 'unit-card', { highlight: isScrolledToElement, - 'outline-card-selected': unit.id === selectedContainerId, + 'outline-card-selected': unit.id === selectedContainerState?.currentId, }, )} data-testid="unit-card" diff --git a/src/data/types.ts b/src/data/types.ts index b08656dd25..8c0fd99d40 100644 --- a/src/data/types.ts +++ b/src/data/types.ts @@ -153,3 +153,9 @@ export interface UserTaskStatusWithUuid { modified: string; uuid: string; } + +export type SelectionState = { + currentId: string; + sectionId?: string; + subsectionId?: string; +} From fd3a70029b63a0034ab0e79b093cf7f1ee129386 Mon Sep 17 00:00:00 2001 From: Navin Karkera Date: Fri, 23 Jan 2026 11:45:19 +0530 Subject: [PATCH 12/60] fix: container selection usage --- src/course-outline/CourseOutline.tsx | 3 +- .../highlights-modal/HighlightsModal.jsx | 4 ++- src/course-outline/hooks.jsx | 34 ++++++++++--------- 3 files changed, 23 insertions(+), 18 deletions(-) diff --git a/src/course-outline/CourseOutline.tsx b/src/course-outline/CourseOutline.tsx index 550b915771..94bbd5384a 100644 --- a/src/course-outline/CourseOutline.tsx +++ b/src/course-outline/CourseOutline.tsx @@ -63,6 +63,7 @@ import OutlineAddChildButtons from './OutlineAddChildButtons'; import { StatusBar } from './status-bar/StatusBar'; import { LegacyStatusBar } from './status-bar/LegacyStatusBar'; import { isOutlineNewDesignEnabled } from './utils'; +import { useCourseItemData } from '@src/course-outline/data/apiHooks'; const CourseOutline = () => { const intl = useIntl(); @@ -163,7 +164,7 @@ const CourseOutline = () => { title: processingNotificationTitle, } = useSelector(getProcessingNotification); - const currentItemData = currentSelection?.currentId; + const { data: currentItemData } = useCourseItemData(currentSelection?.currentId); const itemCategory = currentItemData?.category; const itemCategoryName = COURSE_BLOCK_NAMES[itemCategory]?.name.toLowerCase(); diff --git a/src/course-outline/highlights-modal/HighlightsModal.jsx b/src/course-outline/highlights-modal/HighlightsModal.jsx index 7a80dbe675..4d5cbc9f55 100644 --- a/src/course-outline/highlights-modal/HighlightsModal.jsx +++ b/src/course-outline/highlights-modal/HighlightsModal.jsx @@ -15,6 +15,7 @@ import { HIGHLIGHTS_FIELD_MAX_LENGTH } from '../constants'; import { getHighlightsFormValues } from '../utils'; import messages from './messages'; import { useCourseAuthoringContext } from '@src/CourseAuthoringContext'; +import { useCourseItemData } from '@src/course-outline/data/apiHooks'; const HighlightsModal = ({ isOpen, @@ -23,7 +24,8 @@ const HighlightsModal = ({ }) => { const intl = useIntl(); const { currentSelection } = useCourseAuthoringContext(); - const { highlights = [], displayName } = currentSelection?.currentId || {}; + const { data: currentItemData } = useCourseItemData(currentSelection?.currentId); + const { highlights = [], displayName } = currentItemData || {}; const initialFormValues = getHighlightsFormValues(highlights); const { diff --git a/src/course-outline/hooks.jsx b/src/course-outline/hooks.jsx index 7d9c1b622b..bdf5ad7fd7 100644 --- a/src/course-outline/hooks.jsx +++ b/src/course-outline/hooks.jsx @@ -8,7 +8,7 @@ import { getSavingStatus as getGenericSavingStatus } from '@src/generic/data/sel import { RequestStatus } from '@src/data/constants'; import { useCourseAuthoringContext } from '@src/CourseAuthoringContext'; -import { ContainerType } from '@src/generic/key-utils'; +import { ContainerType, getBlockType } from '@src/generic/key-utils'; import { COURSE_BLOCK_NAMES } from './constants'; import { resetScrollField, @@ -139,7 +139,7 @@ const useCourseOutline = ({ courseId }) => { const handleHighlightsFormSubmit = (highlights) => { const dataToSend = Object.values(highlights).filter(Boolean); - dispatch(updateCourseSectionHighlightsQuery(currentSelection?.currentId.id, dataToSend)); + dispatch(updateCourseSectionHighlightsQuery(currentSelection?.currentId, dataToSend)); closeHighlightsModal(); }; @@ -151,15 +151,16 @@ const useCourseOutline = ({ courseId }) => { }; const handleConfigureItemSubmit = (...arg) => { - switch (currentSelection?.currentId.category) { + const category = getBlockType(currentSelection.currentId) + switch (category) { case COURSE_BLOCK_NAMES.chapter.id: - dispatch(configureCourseSectionQuery(currentSelection?.sectionId.id, ...arg)); + dispatch(configureCourseSectionQuery(currentSelection?.sectionId, ...arg)); break; case COURSE_BLOCK_NAMES.sequential.id: - dispatch(configureCourseSubsectionQuery(currentSelection?.currentId.id, currentSelection?.sectionId.id, ...arg)); + dispatch(configureCourseSubsectionQuery(currentSelection?.currentId, currentSelection?.sectionId, ...arg)); break; case COURSE_BLOCK_NAMES.vertical.id: - dispatch(configureCourseUnitQuery(currentSelection?.currentId.id, currentSelection?.sectionId.id, ...arg)); + dispatch(configureCourseUnitQuery(currentSelection?.currentId, currentSelection?.sectionId, ...arg)); break; default: return; @@ -168,39 +169,40 @@ const useCourseOutline = ({ courseId }) => { }; const handleDeleteItemSubmit = () => { - switch (currentSelection?.currentId.category) { + const category = getBlockType(currentSelection.currentId) + switch (category) { case COURSE_BLOCK_NAMES.chapter.id: - dispatch(deleteCourseSectionQuery(currentSelection?.currentId.id)); + dispatch(deleteCourseSectionQuery(currentSelection?.currentId)); break; case COURSE_BLOCK_NAMES.sequential.id: - dispatch(deleteCourseSubsectionQuery(currentSelection?.currentId.id, currentSelection?.sectionId.id)); + dispatch(deleteCourseSubsectionQuery(currentSelection?.currentId, currentSelection?.sectionId)); break; case COURSE_BLOCK_NAMES.vertical.id: dispatch(deleteCourseUnitQuery( - currentSelection?.currentId.id, - currentSelection?.subsectionId.id, - currentSelection?.sectionId.id, + currentSelection?.currentId, + currentSelection?.subsectionId, + currentSelection?.sectionId, )); break; default: return; } - if (selectedContainerState.currentId === currentSelection?.currentId.id) { + if (selectedContainerState.currentId === currentSelection?.currentId) { clearSelection(); } closeDeleteModal(); }; const handleDuplicateSectionSubmit = () => { - dispatch(duplicateSectionQuery(currentSelection?.sectionId.id, courseStructure.id)); + dispatch(duplicateSectionQuery(currentSelection?.sectionId, courseStructure.id)); }; const handleDuplicateSubsectionSubmit = () => { - dispatch(duplicateSubsectionQuery(currentSelection?.subsectionId.id, currentSelection?.sectionId.id)); + dispatch(duplicateSubsectionQuery(currentSelection?.subsectionId, currentSelection?.sectionId)); }; const handleDuplicateUnitSubmit = () => { - dispatch(duplicateUnitQuery(currentSelection?.currentId.id, currentSelection?.subsectionId.id, currentSelection?.sectionId.id)); + dispatch(duplicateUnitQuery(currentSelection?.currentId, currentSelection?.subsectionId, currentSelection?.sectionId)); }; const handleVideoSharingOptionChange = (value) => { From 77146107b3dc5f2d93a8fb16411d3c72738a08b0 Mon Sep 17 00:00:00 2001 From: Navin Karkera Date: Fri, 23 Jan 2026 16:30:37 +0530 Subject: [PATCH 13/60] feat: unit preview --- .../outline-sidebar/InfoSidebar.tsx | 5 +- .../outline-sidebar/UnitInfoSidebar.tsx | 128 ++++++++++++++++++ .../outline-sidebar/messages.ts | 14 +- .../xblock-container-iframe/index.tsx | 10 +- .../xblock-container-iframe/types.ts | 1 + 5 files changed, 152 insertions(+), 6 deletions(-) create mode 100644 src/course-outline/outline-sidebar/UnitInfoSidebar.tsx diff --git a/src/course-outline/outline-sidebar/InfoSidebar.tsx b/src/course-outline/outline-sidebar/InfoSidebar.tsx index 980ecb06e9..4c8bbb5993 100644 --- a/src/course-outline/outline-sidebar/InfoSidebar.tsx +++ b/src/course-outline/outline-sidebar/InfoSidebar.tsx @@ -2,7 +2,8 @@ import { useOutlineSidebarContext } from "./OutlineSidebarContext"; import { CourseInfoSidebar } from "./CourseInfoSidebar" import { ContainerType, getBlockType } from "@src/generic/key-utils"; import { SectionSidebar } from "./SectionInfoSidebar"; -import { SubsectionSidebar } from "@src/course-outline/outline-sidebar/SubsectionInfoSidebar"; +import { SubsectionSidebar } from "./SubsectionInfoSidebar"; +import { UnitSidebar } from "./UnitInfoSidebar"; export const InfoSidebar = () => { const { selectedContainerState } = useOutlineSidebarContext(); @@ -22,7 +23,7 @@ export const InfoSidebar = () => { return case ContainerType.Vertical: case ContainerType.Unit: - return
Unit sidebar
; + return default: return ; } diff --git a/src/course-outline/outline-sidebar/UnitInfoSidebar.tsx b/src/course-outline/outline-sidebar/UnitInfoSidebar.tsx new file mode 100644 index 0000000000..5056e8b19f --- /dev/null +++ b/src/course-outline/outline-sidebar/UnitInfoSidebar.tsx @@ -0,0 +1,128 @@ +import { useState } from 'react'; +import { useIntl } from '@edx/frontend-platform/i18n'; +import { Tab, Tabs, useToggle } from '@openedx/paragon'; +import { SchoolOutline, Tag } from '@openedx/paragon/icons'; + +import { ContentTagsDrawerSheet, ContentTagsSnippet } from '@src/content-tags-drawer'; +import { ComponentCountSnippet } from '@src/generic/block-type-utils'; +import { useGetBlockTypes } from '@src/search-manager'; + +import { SidebarContent, SidebarSection, SidebarTitle } from '@src/generic/sidebar'; + +import messages from './messages'; +import { useCourseItemData } from '@src/course-outline/data/apiHooks'; +import Loading from '@src/generic/Loading'; +import { LibraryReferenceCard } from './LibraryReferenceCard'; +import { PublishButon } from './PublishButon'; +import { useCourseAuthoringContext } from '@src/CourseAuthoringContext'; +import { useOutlineSidebarContext } from './OutlineSidebarContext'; +import XBlockContainerIframe from '@src/course-unit/xblock-container-iframe'; +import { IframeProvider } from '@src/generic/hooks/context/iFrameContext'; + +interface Props { + unitId: string; +} + +const UnitInfoSidebar = ({ unitId }: Props) => { + const intl = useIntl(); + const { data: componentData } = useGetBlockTypes( + [`breadcrumbs.usage_key = "${unitId}"`], + ); + + const [isManageTagsDrawerOpen, openManageTagsDrawer, closeManageTagsDrawer] = useToggle(false); + + return ( + <> + + + + {componentData && } + + + + + + + + ); +}; + +export const UnitSidebar = ({ unitId }: Props) => { + const intl = useIntl(); + const [tab, setTab] = useState<'preview'| 'info' | 'settings'>('info'); + const { data: unitData, isLoading } = useCourseItemData(unitId); + const { selectedContainerState } = useOutlineSidebarContext(); + const { openPublishModal, courseId } = useCourseAuthoringContext(); + + const handlePublish = () => { + if (selectedContainerState?.sectionId && unitData?.hasChanges) { + openPublishModal({ + value: unitData, + sectionId: selectedContainerState?.sectionId, + }) + } + } + + if (isLoading) { + return ; + } + + return ( + <> + + {unitData?.hasChanges && } + + + + {}, handleDuplicate: () => {}, handleUnlink: () => {} }} + courseVerticalChildren={[]} + handleConfigureSubmit={() => {}} + readonly + /> + + + + + + +
Settings
+
+
+ + ); +} diff --git a/src/course-outline/outline-sidebar/messages.ts b/src/course-outline/outline-sidebar/messages.ts index dd8039c7c2..d80083f4f5 100644 --- a/src/course-outline/outline-sidebar/messages.ts +++ b/src/course-outline/outline-sidebar/messages.ts @@ -135,6 +135,11 @@ const messages = defineMessages({ defaultMessage: 'Subsection Content Summary', description: 'Title of the summary section in the subsection info sidebar', }, + unitContentSummaryText: { + id: 'course-authoring.course-outline.sidebar.unit.content-summary-text', + defaultMessage: 'Unit Content Summary', + description: 'Title of the summary section in the unit info sidebar', + }, publishContainerButton: { id: 'course-authoring.course-outline.sidebar.generic.publish.button', defaultMessage: 'Publish Changes', @@ -145,15 +150,20 @@ const messages = defineMessages({ defaultMessage: '(Draft)', description: 'Draft text in publish button', }, + previewTabText: { + id: 'course-authoring.course-outline.sidebar.generic.preview.tab.text', + defaultMessage: 'Preview', + description: 'Preview tab title in container sidebar', + }, infoTabText: { id: 'course-authoring.course-outline.sidebar.generic.info.tab.text', defaultMessage: 'Details', - description: 'Information tab title in section sidebar', + description: 'Information tab title in container sidebar', }, settingsTabText: { id: 'course-authoring.course-outline.sidebar.generic.info.settings.text', defaultMessage: 'Settings', - description: 'Settings tab title in section sidebar', + description: 'Settings tab title in container sidebar', }, libraryReferenceCardText: { id: 'course-authoring.course-outline.sidebar.library.reference.card.text', diff --git a/src/course-unit/xblock-container-iframe/index.tsx b/src/course-unit/xblock-container-iframe/index.tsx index 5b67b340af..4dec9fa425 100644 --- a/src/course-unit/xblock-container-iframe/index.tsx +++ b/src/course-unit/xblock-container-iframe/index.tsx @@ -41,7 +41,13 @@ import { import { formatAccessManagedXBlockData, getIframeUrl, getLegacyEditModalUrl } from './utils'; const XBlockContainerIframe: FC = ({ - courseId, blockId, unitXBlockActions, courseVerticalChildren, handleConfigureSubmit, isUnitVerticalType, + courseId, + blockId, + unitXBlockActions, + courseVerticalChildren, + handleConfigureSubmit, + isUnitVerticalType, + readonly, }) => { const intl = useIntl(); const dispatch = useDispatch(); @@ -210,7 +216,7 @@ const XBlockContainerIframe: FC = ({ handleRefreshIframe, }); - useIframeMessages(messageHandlers); + useIframeMessages(readonly ? {}: messageHandlers); return ( <> diff --git a/src/course-unit/xblock-container-iframe/types.ts b/src/course-unit/xblock-container-iframe/types.ts index a4051cbe3c..8115dca47e 100644 --- a/src/course-unit/xblock-container-iframe/types.ts +++ b/src/course-unit/xblock-container-iframe/types.ts @@ -37,6 +37,7 @@ export interface XBlockContainerIframeProps { }; courseVerticalChildren: Array; handleConfigureSubmit: (XBlockId: string, ...args: any[]) => void; + readonly?: boolean; } export type AccessManagedXBlockDataTypes = { From 011b71213a0a0ba3906ca8f63acca852dafe7d08 Mon Sep 17 00:00:00 2001 From: Navin Karkera Date: Fri, 23 Jan 2026 18:33:54 +0530 Subject: [PATCH 14/60] feat: open button --- .../outline-sidebar/UnitInfoSidebar.tsx | 22 +++++++++++++++---- .../outline-sidebar/messages.ts | 5 +++++ 2 files changed, 23 insertions(+), 4 deletions(-) diff --git a/src/course-outline/outline-sidebar/UnitInfoSidebar.tsx b/src/course-outline/outline-sidebar/UnitInfoSidebar.tsx index 5056e8b19f..8437620fd8 100644 --- a/src/course-outline/outline-sidebar/UnitInfoSidebar.tsx +++ b/src/course-outline/outline-sidebar/UnitInfoSidebar.tsx @@ -1,7 +1,7 @@ import { useState } from 'react'; import { useIntl } from '@edx/frontend-platform/i18n'; -import { Tab, Tabs, useToggle } from '@openedx/paragon'; -import { SchoolOutline, Tag } from '@openedx/paragon/icons'; +import { Button, Stack, Tab, Tabs, useToggle } from '@openedx/paragon'; +import { Expand, OpenInFull, SchoolOutline, Tag } from '@openedx/paragon/icons'; import { ContentTagsDrawerSheet, ContentTagsSnippet } from '@src/content-tags-drawer'; import { ComponentCountSnippet } from '@src/generic/block-type-utils'; @@ -18,6 +18,7 @@ import { useCourseAuthoringContext } from '@src/CourseAuthoringContext'; import { useOutlineSidebarContext } from './OutlineSidebarContext'; import XBlockContainerIframe from '@src/course-unit/xblock-container-iframe'; import { IframeProvider } from '@src/generic/hooks/context/iFrameContext'; +import { Link } from 'react-router-dom'; interface Props { unitId: string; @@ -68,7 +69,7 @@ export const UnitSidebar = ({ unitId }: Props) => { const [tab, setTab] = useState<'preview'| 'info' | 'settings'>('info'); const { data: unitData, isLoading } = useCourseItemData(unitId); const { selectedContainerState } = useOutlineSidebarContext(); - const { openPublishModal, courseId } = useCourseAuthoringContext(); + const { openPublishModal, getUnitUrl, courseId } = useCourseAuthoringContext(); const handlePublish = () => { if (selectedContainerState?.sectionId && unitData?.hasChanges) { @@ -89,7 +90,20 @@ export const UnitSidebar = ({ unitId }: Props) => { title={unitData?.displayName || ''} icon={SchoolOutline} /> - {unitData?.hasChanges && } + + + {unitData?.hasChanges && ( + + )} + Date: Fri, 23 Jan 2026 21:39:20 +0530 Subject: [PATCH 15/60] refactor: align sidebar respects selection --- src/CourseAuthoringContext.tsx | 9 +++- src/course-outline/card-header/CardHeader.tsx | 3 +- .../outline-sidebar/LibraryReferenceCard.tsx | 27 +++++++++-- .../outline-sidebar/OutlineAlignSidebar.tsx | 20 ++++---- .../outline-sidebar/OutlineSidebarContext.tsx | 46 +++++++++++++------ .../outline-sidebar/UnitInfoSidebar.tsx | 4 +- .../section-card/SectionCard.tsx | 2 +- .../subsection-card/SubsectionCard.tsx | 2 +- src/course-outline/unit-card/UnitCard.tsx | 6 +-- 9 files changed, 81 insertions(+), 38 deletions(-) diff --git a/src/CourseAuthoringContext.tsx b/src/CourseAuthoringContext.tsx index 8980fb0429..6ef90c6d78 100644 --- a/src/CourseAuthoringContext.tsx +++ b/src/CourseAuthoringContext.tsx @@ -43,7 +43,7 @@ export type CourseAuthoringContextData = { openPublishModal: (value: ModalState) => void; closePublishModal: () => void; currentSelection?: SelectionState; - setCurrentSelection: (value?: SelectionState) => void; + setCurrentSelection: React.Dispatch>; }; /** @@ -73,6 +73,13 @@ export const CourseAuthoringProvider = ({ const { id: courseUsageKey } = courseStructure || {}; const [isUnlinkModalOpen, currentUnlinkModalData, openUnlinkModal, closeUnlinkModal] = useToggleWithValue(); const [isPublishModalOpen, currentPublishModalData, openPublishModal, closePublishModal] = useToggleWithValue(); + /** + * This will hold the state of current item that is being operated on, + * For example: + * - the details of container that is being edited. + * - the details of container of which see more dropdown is open. + * It is mostly used in modals which should be soon be replaced with its equivalent in sidebar. + */ const [currentSelection, setCurrentSelection] = useState(); const queryClient = useQueryClient(); diff --git a/src/course-outline/card-header/CardHeader.tsx b/src/course-outline/card-header/CardHeader.tsx index 82280c9a3e..b5b5150bf7 100644 --- a/src/course-outline/card-header/CardHeader.tsx +++ b/src/course-outline/card-header/CardHeader.tsx @@ -112,7 +112,8 @@ const CardHeader = ({ const openManageTagsDrawer = useCallback(() => { const showNewSidebar = getConfig().ENABLE_COURSE_OUTLINE_NEW_DESIGN?.toString().toLowerCase() === 'true'; if (showNewSidebar) { - setCurrentPageKey('align', cardId); + setCurrentPageKey('align'); + onClickMenuButton(); } else { openLegacyTagsDrawer(); } diff --git a/src/course-outline/outline-sidebar/LibraryReferenceCard.tsx b/src/course-outline/outline-sidebar/LibraryReferenceCard.tsx index fbaf335102..5e064e5958 100644 --- a/src/course-outline/outline-sidebar/LibraryReferenceCard.tsx +++ b/src/course-outline/outline-sidebar/LibraryReferenceCard.tsx @@ -8,7 +8,7 @@ import { useOutlineSidebarContext } from "@src/course-outline/outline-sidebar/Ou import { PreviewLibraryXBlockChanges } from "@src/course-unit/preview-changes"; import { useCourseAuthoringContext } from "@src/CourseAuthoringContext"; import { XBlock } from "@src/data/types"; -import { getBlockType, normalizeContainerType } from "@src/generic/key-utils"; +import { ContainerType, getBlockType, normalizeContainerType } from "@src/generic/key-utils"; import { useToggleWithValue } from "@src/hooks"; import { useQueryClient } from "@tanstack/react-query"; import { useCallback, useMemo } from "react"; @@ -42,6 +42,27 @@ const HasTopParentTextAndButton = ({ blockData, displayName, openSyncModal }: Su openSyncModal(parentData); }; + const handleGoToParent = () => { + // istanbul ignore: to satisfy checker + if (!upstreamInfo?.topLevelParentKey) { + return null; + } + const category = getBlockType(upstreamInfo.topLevelParentKey) as ContainerType; + if ([ContainerType.Chapter, ContainerType.Section].includes(category)) { + return openContainerInfoSidebar( + upstreamInfo.topLevelParentKey, + undefined, + upstreamInfo.topLevelParentKey, + ) + } + // Only possible option is sequential or subsection + return openContainerInfoSidebar( + upstreamInfo.topLevelParentKey, + upstreamInfo.topLevelParentKey, + selectedContainerState?.sectionId + ) + } + if (!upstreamInfo?.topLevelParentKey) { return null; } @@ -87,9 +108,7 @@ const HasTopParentTextAndButton = ({ blockData, displayName, openSyncModal }: Su diff --git a/src/course-outline/outline-sidebar/OutlineAlignSidebar.tsx b/src/course-outline/outline-sidebar/OutlineAlignSidebar.tsx index 73593d0572..9ba4a839b1 100644 --- a/src/course-outline/outline-sidebar/OutlineAlignSidebar.tsx +++ b/src/course-outline/outline-sidebar/OutlineAlignSidebar.tsx @@ -2,23 +2,19 @@ import { SchoolOutline } from '@openedx/paragon/icons'; import { ContentTagsDrawer } from '@src/content-tags-drawer'; import { useContentData } from '@src/content-tags-drawer/data/apiHooks'; import { useCourseAuthoringContext } from '@src/CourseAuthoringContext'; -import { useCourseDetails } from '@src/data/apiHooks'; import { SidebarTitle } from '@src/generic/sidebar'; +import { useMemo } from 'react'; import { useOutlineSidebarContext } from './OutlineSidebarContext'; export const OutlineAlignSidebar = () => { - const { courseId } = useCourseAuthoringContext(); - const { currentContainerId } = useOutlineSidebarContext(); + const { courseId, currentSelection } = useCourseAuthoringContext(); + const { selectedContainerState } = useOutlineSidebarContext(); - const sidebarContentId = currentContainerId || courseId; + const sidebarContentId = useMemo(() => { + return currentSelection?.currentId || selectedContainerState?.currentId || courseId + }, [currentSelection, selectedContainerState, courseId]); - const { - data: courseData, - } = useCourseDetails(courseId); - - const { - data: contentData, - } = useContentData(currentContainerId); + const { data: contentData } = useContentData(sidebarContentId); return (
@@ -26,7 +22,7 @@ export const OutlineAlignSidebar = () => { title={ contentData && 'displayName' in contentData ? contentData.displayName - : courseData?.name || '' + : contentData?.courseDisplayNameWithDefault || '' } icon={SchoolOutline} /> diff --git a/src/course-outline/outline-sidebar/OutlineSidebarContext.tsx b/src/course-outline/outline-sidebar/OutlineSidebarContext.tsx index 4139d4f7fa..5630113b9f 100644 --- a/src/course-outline/outline-sidebar/OutlineSidebarContext.tsx +++ b/src/course-outline/outline-sidebar/OutlineSidebarContext.tsx @@ -2,6 +2,7 @@ import { createContext, useCallback, useContext, + useEffect, useMemo, useState, } from 'react'; @@ -10,6 +11,7 @@ import { useToggle } from '@openedx/paragon'; import { useEscapeClick, useStateWithUrlSearchParam } from '@src/hooks'; import { isOutlineNewDesignEnabled } from '../utils'; import { SelectionState } from '@src/data/types'; +import { useCourseAuthoringContext } from '@src/CourseAuthoringContext'; export type OutlineSidebarPageKeys = 'help' | 'info' | 'add' | 'align'; export type OutlineFlowType = 'use-section' | 'use-subsection' | 'use-unit' | null; @@ -25,7 +27,7 @@ export type OutlineFlow = { interface OutlineSidebarContextData { currentPageKey: OutlineSidebarPageKeys; - setCurrentPageKey: (pageKey: OutlineSidebarPageKeys, containerId?: string) => void; + setCurrentPageKey: (pageKey: OutlineSidebarPageKeys) => void; currentFlow: OutlineFlow | null; startCurrentFlow: (flow: OutlineFlow) => void; stopCurrentFlow: () => void; @@ -33,17 +35,12 @@ interface OutlineSidebarContextData { open: () => void; toggle: () => void; selectedContainerState?: SelectionState; - // The Id of the container used in the current sidebar page - // The container is not necessarily selected to open a selected sidebar. - // Example: Align sidebar - currentContainerId?: string; - openContainerInfoSidebar: (containerId: string, sectionId?: string) => void; + openContainerInfoSidebar: (containerId: string, subsectionId?: string, sectionId?: string) => void; } const OutlineSidebarContext = createContext(undefined); export const OutlineSidebarProvider = ({ children }: { children?: React.ReactNode }) => { - const [currentContainerId, setCurrentContainerId] = useState(); const [currentPageKey, setCurrentPageKeyState] = useStateWithUrlSearchParam( 'info', 'sidebar', @@ -53,7 +50,29 @@ export const OutlineSidebarProvider = ({ children }: { children?: React.ReactNod const [currentFlow, setCurrentFlow] = useState(null); const [isOpen, open, , toggle] = useToggle(true); + /** + * Use this to store the selected container's information and should always contain full ancestor info. + * If selected container is a section, set containerId and sectionId to same value and subsectionId should + * be undefined. + * If selected container is a subsection, set containerId and subsectionId to same value and sectionId + * should be set to its parent section id. + * If selected container is an unit, set containerId as unitId, subsectionId as its parent subsection's id + * and sectionId should be set to its top parent section's id. + */ const [selectedContainerState, setSelectedContainerState] = useState(); + const { setCurrentSelection } = useCourseAuthoringContext(); + + /** + * Set currentSelection to same as selectedContainerState whenever + * selectedContainerState or currentPageKey changes. + * This allows us to reset the currentSelection. + */ + useEffect(() => { + // To allow tag buttons on other cards to jump to align page and not loose its selection + if (currentPageKey !== 'align') { + setCurrentSelection(selectedContainerState); + } + }, [currentPageKey, selectedContainerState]); /** * Stops current add content flow. @@ -63,16 +82,19 @@ export const OutlineSidebarProvider = ({ children }: { children?: React.ReactNod setCurrentFlow(null); }, [setCurrentFlow]); - const setCurrentPageKey = useCallback((pageKey: OutlineSidebarPageKeys, containerId?: string) => { + const setCurrentPageKey = useCallback((pageKey: OutlineSidebarPageKeys) => { setCurrentPageKeyState(pageKey); setCurrentFlow(null); - setCurrentContainerId(containerId); open(); }, [open, setCurrentFlow]); - const openContainerInfoSidebar = useCallback((containerId: string, sectionId?: string) => { + const openContainerInfoSidebar = useCallback(( + containerId: string, + subsectionId?: string, + sectionId?: string + ) => { if (isOutlineNewDesignEnabled()) { - setSelectedContainerState({ currentId: containerId, sectionId }); + setSelectedContainerState({ currentId: containerId, subsectionId, sectionId }); setCurrentPageKey('info'); } }, [setSelectedContainerState, setCurrentPageKey]); @@ -110,7 +132,6 @@ export const OutlineSidebarProvider = ({ children }: { children?: React.ReactNod open, toggle, selectedContainerState, - currentContainerId, openContainerInfoSidebar, clearSelection, }), @@ -124,7 +145,6 @@ export const OutlineSidebarProvider = ({ children }: { children?: React.ReactNod open, toggle, selectedContainerState, - currentContainerId, openContainerInfoSidebar, clearSelection, ], diff --git a/src/course-outline/outline-sidebar/UnitInfoSidebar.tsx b/src/course-outline/outline-sidebar/UnitInfoSidebar.tsx index 8437620fd8..4324be6a8e 100644 --- a/src/course-outline/outline-sidebar/UnitInfoSidebar.tsx +++ b/src/course-outline/outline-sidebar/UnitInfoSidebar.tsx @@ -112,7 +112,7 @@ export const UnitSidebar = ({ unitId }: Props) => { onSelect={setTab} mountOnEnter > - { readonly /> - + diff --git a/src/course-outline/section-card/SectionCard.tsx b/src/course-outline/section-card/SectionCard.tsx index 2d1a8aea7b..260cbb2da8 100644 --- a/src/course-outline/section-card/SectionCard.tsx +++ b/src/course-outline/section-card/SectionCard.tsx @@ -225,7 +225,7 @@ const SectionCard = ({ const onClickCard = useCallback((e: React.MouseEvent, preventNodeEvents: boolean) => { if (!preventNodeEvents || e.target === e.currentTarget) { - openContainerInfoSidebar(section.id, section.id); + openContainerInfoSidebar(section.id, undefined, section.id); setIsExpanded(true); } }, [openContainerInfoSidebar]); diff --git a/src/course-outline/subsection-card/SubsectionCard.tsx b/src/course-outline/subsection-card/SubsectionCard.tsx index 514419f99c..03636b313e 100644 --- a/src/course-outline/subsection-card/SubsectionCard.tsx +++ b/src/course-outline/subsection-card/SubsectionCard.tsx @@ -229,7 +229,7 @@ const SubsectionCard = ({ const onClickCard = useCallback((e: React.MouseEvent, preventNodeEvents: boolean) => { if (!preventNodeEvents || e.target === e.currentTarget) { - openContainerInfoSidebar(subsection.id, section.id); + openContainerInfoSidebar(subsection.id, subsection.id, section.id); setIsExpanded(true); } }, [openContainerInfoSidebar]); diff --git a/src/course-outline/unit-card/UnitCard.tsx b/src/course-outline/unit-card/UnitCard.tsx index 8ad331476a..25448c43ce 100644 --- a/src/course-outline/unit-card/UnitCard.tsx +++ b/src/course-outline/unit-card/UnitCard.tsx @@ -128,7 +128,7 @@ const UnitCard = ({ }); const borderStyle = getItemStatusBorder(unitStatus); - const handleClickMenuButton = () => { + const selectAndTrigger = () => { setCurrentSelection({ currentId: unit.id, subsectionId: subsection.id, @@ -158,7 +158,7 @@ const UnitCard = ({ const onClickCard = useCallback((e: React.MouseEvent) => { if (e.target === e.currentTarget) { - openContainerInfoSidebar(unit.id, section.id); + openContainerInfoSidebar(unit.id, subsection.id, section.id); } }, [openContainerInfoSidebar]); @@ -244,7 +244,7 @@ const UnitCard = ({ status={unitStatus} hasChanges={hasChanges} cardId={id} - onClickMenuButton={handleClickMenuButton} + onClickMenuButton={selectAndTrigger} onClickPublish={() => openPublishModal({ value: unit, sectionId: section.id })} onClickConfigure={onOpenConfigureModal} onClickDelete={onOpenDeleteModal} From dd47229b76a7a631acfbd40e25ee1e466a73bff9 Mon Sep 17 00:00:00 2001 From: Navin Karkera Date: Fri, 23 Jan 2026 22:06:15 +0530 Subject: [PATCH 16/60] fix: lint and type issues --- src/CourseAuthoringContext.tsx | 48 ++++++--------- src/course-outline/CourseOutline.tsx | 5 +- src/course-outline/card-header/CardHeader.tsx | 8 +-- src/course-outline/data/apiHooks.ts | 13 ++-- .../header-navigations/HeaderActions.test.tsx | 2 +- .../header-navigations/HeaderActions.tsx | 10 ++- .../highlights-modal/HighlightsModal.jsx | 4 +- src/course-outline/hooks.jsx | 42 ++++++++++--- .../outline-sidebar/InfoSidebar.tsx | 23 ++++--- .../outline-sidebar/LibraryReferenceCard.tsx | 61 ++++++++++--------- .../outline-sidebar/OutlineAlignSidebar.tsx | 8 ++- .../outline-sidebar/OutlineSidebarContext.tsx | 5 +- .../outline-sidebar/PublishButon.tsx | 35 +++++------ .../outline-sidebar/SectionInfoSidebar.tsx | 12 ++-- .../outline-sidebar/SubsectionInfoSidebar.tsx | 14 ++--- .../outline-sidebar/UnitInfoSidebar.tsx | 26 ++++---- .../publish-modal/PublishModal.tsx | 32 +++++----- .../section-card/SectionCard.test.tsx | 7 +-- .../section-card/SectionCard.tsx | 6 +- .../subsection-card/SubsectionCard.test.tsx | 7 +-- .../subsection-card/SubsectionCard.tsx | 6 +- .../unit-card/UnitCard.test.tsx | 8 +-- src/course-outline/unit-card/UnitCard.tsx | 13 ++-- .../xblock-container-iframe/index.tsx | 2 +- .../xblock-container-iframe/types.ts | 2 +- src/data/types.ts | 2 +- .../configure-modal/ConfigureModal.jsx | 4 +- src/generic/unlink-modal/data/apiHooks.ts | 2 +- src/hooks.ts | 1 - 29 files changed, 221 insertions(+), 187 deletions(-) diff --git a/src/CourseAuthoringContext.tsx b/src/CourseAuthoringContext.tsx index 6ef90c6d78..7dffded8d4 100644 --- a/src/CourseAuthoringContext.tsx +++ b/src/CourseAuthoringContext.tsx @@ -1,5 +1,7 @@ import { getConfig } from '@edx/frontend-platform'; -import { createContext, useCallback, useContext, useMemo, useState } from 'react'; +import { + createContext, useContext, useMemo, useState, +} from 'react'; import { getAuthenticatedUser } from '@edx/frontend-platform/auth'; import { courseOutlineQueryKeys, useCreateCourseBlock } from '@src/course-outline/data/apiHooks'; import { getCourseItem } from '@src/course-outline/data/api'; @@ -7,19 +9,17 @@ import { useDispatch, useSelector } from 'react-redux'; import { addSection, addSubsection, updateSavingStatus } from '@src/course-outline/data/slice'; import { useNavigate } from 'react-router'; import { getOutlineIndexData } from '@src/course-outline/data/selectors'; -import { RequestStatus, RequestStatusType } from './data/constants'; -import { useCourseDetails, useWaffleFlags } from './data/apiHooks'; -import { CourseDetailsData } from './data/api'; import { useToggleWithValue } from '@src/hooks'; import { SelectionState, XBlock } from '@src/data/types'; -import { useUnlinkDownstream } from '@src/generic/unlink-modal'; -import { fetchCourseSectionQuery } from '@src/course-outline/data/thunk'; import { useQueryClient } from '@tanstack/react-query'; +import { CourseDetailsData } from './data/api'; +import { useCourseDetails, useWaffleFlags } from './data/apiHooks'; +import { RequestStatus, RequestStatusType } from './data/constants'; type ModalState = { value: XBlock; sectionId: string; -} +}; export type CourseAuthoringContextData = { /** The ID of the current course */ @@ -37,7 +37,6 @@ export type CourseAuthoringContextData = { currentUnlinkModalData?: ModalState; openUnlinkModal: (value: ModalState) => void; closeUnlinkModal: () => void; - handleUnlinkItemSubmit: () => Promise; isPublishModalOpen: boolean; currentPublishModalData?: ModalState; openPublishModal: (value: ModalState) => void; @@ -71,8 +70,18 @@ export const CourseAuthoringProvider = ({ const canChangeProviders = getAuthenticatedUser().administrator || new Date(courseDetails?.start ?? 0) > new Date(); const { courseStructure } = useSelector(getOutlineIndexData); const { id: courseUsageKey } = courseStructure || {}; - const [isUnlinkModalOpen, currentUnlinkModalData, openUnlinkModal, closeUnlinkModal] = useToggleWithValue(); - const [isPublishModalOpen, currentPublishModalData, openPublishModal, closePublishModal] = useToggleWithValue(); + const [ + isUnlinkModalOpen, + currentUnlinkModalData, + openUnlinkModal, + closeUnlinkModal, + ] = useToggleWithValue(); + const [ + isPublishModalOpen, + currentPublishModalData, + openPublishModal, + closePublishModal, + ] = useToggleWithValue(); /** * This will hold the state of current item that is being operated on, * For example: @@ -91,23 +100,6 @@ export const CourseAuthoringProvider = ({ return `${getConfig().STUDIO_BASE_URL}/container/${locator}`; }; - const { mutateAsync: unlinkDownstream } = useUnlinkDownstream(); - - /** Handle the submit of the item unlinking XBlock from library counterpart. */ - const handleUnlinkItemSubmit = useCallback(async () => { - // istanbul ignore if: this should never happen - if (!currentUnlinkModalData) { - return; - } - - await unlinkDownstream(currentUnlinkModalData.value.id, { - onSuccess: () => { - dispatch(fetchCourseSectionQuery([currentUnlinkModalData.sectionId])) - closeUnlinkModal(); - }, - }); - }, [currentUnlinkModalData, unlinkDownstream, closeUnlinkModal, fetchCourseSectionQuery]); - /** * Open the unit page for a given locator. */ @@ -169,7 +161,6 @@ export const CourseAuthoringProvider = ({ openUnlinkModal, closeUnlinkModal, currentUnlinkModalData, - handleUnlinkItemSubmit, isPublishModalOpen, currentPublishModalData, openPublishModal, @@ -191,7 +182,6 @@ export const CourseAuthoringProvider = ({ openUnlinkModal, closeUnlinkModal, currentUnlinkModalData, - handleUnlinkItemSubmit, isPublishModalOpen, currentPublishModalData, openPublishModal, diff --git a/src/course-outline/CourseOutline.tsx b/src/course-outline/CourseOutline.tsx index 94bbd5384a..e693c26e59 100644 --- a/src/course-outline/CourseOutline.tsx +++ b/src/course-outline/CourseOutline.tsx @@ -36,6 +36,7 @@ import { XBlock } from '@src/data/types'; import { useCourseAuthoringContext } from '@src/CourseAuthoringContext'; import LegacyLibContentBlockAlert from '@src/course-libraries/LegacyLibContentBlockAlert'; import { ContainerType } from '@src/generic/key-utils'; +import { useCourseItemData } from '@src/course-outline/data/apiHooks'; import { getProctoredExamsFlag, getTimedExamsFlag, @@ -63,7 +64,6 @@ import OutlineAddChildButtons from './OutlineAddChildButtons'; import { StatusBar } from './status-bar/StatusBar'; import { LegacyStatusBar } from './status-bar/LegacyStatusBar'; import { isOutlineNewDesignEnabled } from './utils'; -import { useCourseItemData } from '@src/course-outline/data/apiHooks'; const CourseOutline = () => { const intl = useIntl(); @@ -76,7 +76,6 @@ const CourseOutline = () => { handleAddSection, isUnlinkModalOpen, closeUnlinkModal, - handleUnlinkItemSubmit, currentSelection, } = useCourseAuthoringContext(); @@ -130,6 +129,7 @@ const CourseOutline = () => { handleUnitDragAndDrop, errors, resetScrollState, + handleUnlinkItemSubmit, } = useCourseOutline({ courseId }); // Show the new actions bar if it is enabled in the configuration. @@ -436,7 +436,6 @@ const CourseOutline = () => { subsection, subsection.childInfo.children, )} - savingStatus={savingStatus} onOpenConfigureModal={openConfigureModal} onOpenDeleteModal={openDeleteModal} onDuplicateSubmit={handleDuplicateUnitSubmit} diff --git a/src/course-outline/card-header/CardHeader.tsx b/src/course-outline/card-header/CardHeader.tsx index b5b5150bf7..7f6acec7e8 100644 --- a/src/course-outline/card-header/CardHeader.tsx +++ b/src/course-outline/card-header/CardHeader.tsx @@ -25,14 +25,14 @@ import { ContentTagsDrawerSheet } from '@src/content-tags-drawer'; import TagCount from '@src/generic/tag-count'; import { useEscapeClick } from '@src/hooks'; import { XBlockActions } from '@src/data/types'; +import { courseOutlineQueryKeys, useUpdateCourseBlockName } from '@src/course-outline/data/apiHooks'; +import { useCourseAuthoringContext } from '@src/CourseAuthoringContext'; +import { useQueryClient } from '@tanstack/react-query'; import { ITEM_BADGE_STATUS } from '../constants'; import { scrollToElement } from '../utils'; import CardStatus from './CardStatus'; import messages from './messages'; import { useOutlineSidebarContext } from '../outline-sidebar/OutlineSidebarContext'; -import { courseOutlineQueryKeys, useUpdateCourseBlockName } from '@src/course-outline/data/apiHooks'; -import { useCourseAuthoringContext } from '@src/CourseAuthoringContext'; -import { useQueryClient } from '@tanstack/react-query'; interface CardHeaderProps { title: string; @@ -134,7 +134,7 @@ const CardHeader = ({ const onEditClick = () => { onClickMenuButton(); openForm(); - } + }; useEffect(() => { const locatorId = searchParams.get('show'); diff --git a/src/course-outline/data/apiHooks.ts b/src/course-outline/data/apiHooks.ts index 3f876e4f11..f3030858f9 100644 --- a/src/course-outline/data/apiHooks.ts +++ b/src/course-outline/data/apiHooks.ts @@ -1,8 +1,12 @@ import { containerComparisonQueryKeys } from '@src/container-comparison/data/apiHooks'; import type { XBlock } from '@src/data/types'; import { getCourseKey } from '@src/generic/key-utils'; -import { skipToken, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; -import { createCourseXblock, editItemDisplayName, getCourseDetails, getCourseItem, publishCourseItem } from './api'; +import { + skipToken, useMutation, useQuery, useQueryClient, +} from '@tanstack/react-query'; +import { + createCourseXblock, editItemDisplayName, getCourseDetails, getCourseItem, publishCourseItem, +} from './api'; export const courseOutlineQueryKeys = { all: ['courseOutline'], @@ -11,7 +15,7 @@ export const courseOutlineQueryKeys = { */ course: (courseId?: string) => [...courseOutlineQueryKeys.all, courseId], courseItemId: (itemId?: string) => [ - ...courseOutlineQueryKeys.course(itemId ? getCourseKey(itemId): undefined), + ...courseOutlineQueryKeys.course(itemId ? getCourseKey(itemId) : undefined), itemId, ], courseDetails: (courseId?: string) => [ @@ -44,7 +48,7 @@ export const useCreateCourseBlock = ( queryClient.invalidateQueries({ queryKey: courseOutlineQueryKeys.courseItemId(variables.parentLocator) }); callback?.(data.locator, variables.parentLocator); }, - }) + }); }; export const useCourseItemData = (itemId?: string, initialData?: XBlock, enabled: boolean = true) => ( @@ -83,4 +87,3 @@ export const usePublishCourseItem = (sectionId?: string) => { }, }); }; - diff --git a/src/course-outline/header-navigations/HeaderActions.test.tsx b/src/course-outline/header-navigations/HeaderActions.test.tsx index 54b12168ea..f5d4d76257 100644 --- a/src/course-outline/header-navigations/HeaderActions.test.tsx +++ b/src/course-outline/header-navigations/HeaderActions.test.tsx @@ -3,9 +3,9 @@ import { fireEvent, initializeMocks, render, screen, } from '@src/testUtils'; +import { OutlineSidebarProvider } from '@src/course-outline'; import messages from './messages'; import HeaderActions, { HeaderActionsProps } from './HeaderActions'; -import { OutlineSidebarProvider } from '@src/course-outline'; const headerNavigationsActions = { lmsLink: '', diff --git a/src/course-outline/header-navigations/HeaderActions.tsx b/src/course-outline/header-navigations/HeaderActions.tsx index bddd876e94..5406c96b15 100644 --- a/src/course-outline/header-navigations/HeaderActions.tsx +++ b/src/course-outline/header-navigations/HeaderActions.tsx @@ -28,7 +28,13 @@ const HeaderActions = ({ const intl = useIntl(); const { lmsLink } = actions; - const { setCurrentPageKey } = useOutlineSidebarContext(); + const { clearSelection, open, setCurrentPageKey } = useOutlineSidebarContext(); + + const handleCourseInfoClick = () => { + clearSelection(); + setCurrentPageKey('info'); + open(); + }; return ( @@ -42,7 +48,7 @@ const HeaderActions = ({ > ); -} +}; const TopLevelTextAndButton = ({ blockData, displayName, openSyncModal }: SubProps) => { const { upstreamInfo } = blockData; @@ -122,7 +124,7 @@ const TopLevelTextAndButton = ({ blockData, displayName, openSyncModal }: SubPro const { openUnlinkModal } = useCourseAuthoringContext(); const messageValues = { name: displayName, - } + }; const handleUnlinkClick = () => { if (!selectedContainerState?.sectionId) { @@ -140,7 +142,7 @@ const TopLevelTextAndButton = ({ blockData, displayName, openSyncModal }: SubPro
- ) + ); }; - diff --git a/src/course-outline/outline-sidebar/OutlineAlignSidebar.tsx b/src/course-outline/outline-sidebar/OutlineAlignSidebar.tsx index 9ba4a839b1..25fb78362d 100644 --- a/src/course-outline/outline-sidebar/OutlineAlignSidebar.tsx +++ b/src/course-outline/outline-sidebar/OutlineAlignSidebar.tsx @@ -10,9 +10,11 @@ export const OutlineAlignSidebar = () => { const { courseId, currentSelection } = useCourseAuthoringContext(); const { selectedContainerState } = useOutlineSidebarContext(); - const sidebarContentId = useMemo(() => { - return currentSelection?.currentId || selectedContainerState?.currentId || courseId - }, [currentSelection, selectedContainerState, courseId]); + const sidebarContentId = useMemo(() => currentSelection?.currentId || selectedContainerState?.currentId || courseId, [ + currentSelection, + selectedContainerState, + courseId, + ]); const { data: contentData } = useContentData(sidebarContentId); diff --git a/src/course-outline/outline-sidebar/OutlineSidebarContext.tsx b/src/course-outline/outline-sidebar/OutlineSidebarContext.tsx index 5630113b9f..57a8833ce3 100644 --- a/src/course-outline/outline-sidebar/OutlineSidebarContext.tsx +++ b/src/course-outline/outline-sidebar/OutlineSidebarContext.tsx @@ -9,9 +9,9 @@ import { import { useToggle } from '@openedx/paragon'; import { useEscapeClick, useStateWithUrlSearchParam } from '@src/hooks'; -import { isOutlineNewDesignEnabled } from '../utils'; import { SelectionState } from '@src/data/types'; import { useCourseAuthoringContext } from '@src/CourseAuthoringContext'; +import { isOutlineNewDesignEnabled } from '../utils'; export type OutlineSidebarPageKeys = 'help' | 'info' | 'add' | 'align'; export type OutlineFlowType = 'use-section' | 'use-subsection' | 'use-unit' | null; @@ -36,6 +36,7 @@ interface OutlineSidebarContextData { toggle: () => void; selectedContainerState?: SelectionState; openContainerInfoSidebar: (containerId: string, subsectionId?: string, sectionId?: string) => void; + clearSelection: () => void; } const OutlineSidebarContext = createContext(undefined); @@ -91,7 +92,7 @@ export const OutlineSidebarProvider = ({ children }: { children?: React.ReactNod const openContainerInfoSidebar = useCallback(( containerId: string, subsectionId?: string, - sectionId?: string + sectionId?: string, ) => { if (isOutlineNewDesignEnabled()) { setSelectedContainerState({ currentId: containerId, subsectionId, sectionId }); diff --git a/src/course-outline/outline-sidebar/PublishButon.tsx b/src/course-outline/outline-sidebar/PublishButon.tsx index 5e4aaf612a..0288192c94 100644 --- a/src/course-outline/outline-sidebar/PublishButon.tsx +++ b/src/course-outline/outline-sidebar/PublishButon.tsx @@ -1,25 +1,22 @@ -import { FormattedMessage } from "@edx/frontend-platform/i18n" -import { Button } from "@openedx/paragon" +import { FormattedMessage } from '@edx/frontend-platform/i18n'; +import { Button } from '@openedx/paragon'; import messages from './messages'; interface Props { onClick: () => void; } -export const PublishButon = ({ onClick }: Props) => { - return ( - - ) -} - +export const PublishButon = ({ onClick }: Props) => ( + +); diff --git a/src/course-outline/outline-sidebar/SectionInfoSidebar.tsx b/src/course-outline/outline-sidebar/SectionInfoSidebar.tsx index 0741ab8b40..899cb904c6 100644 --- a/src/course-outline/outline-sidebar/SectionInfoSidebar.tsx +++ b/src/course-outline/outline-sidebar/SectionInfoSidebar.tsx @@ -9,12 +9,12 @@ import { useGetBlockTypes } from '@src/search-manager'; import { SidebarContent, SidebarSection, SidebarTitle } from '@src/generic/sidebar'; -import messages from './messages'; import { useCourseItemData } from '@src/course-outline/data/apiHooks'; import Loading from '@src/generic/Loading'; +import { useCourseAuthoringContext } from '@src/CourseAuthoringContext'; +import messages from './messages'; import { LibraryReferenceCard } from './LibraryReferenceCard'; import { PublishButon } from './PublishButon'; -import { useCourseAuthoringContext } from '@src/CourseAuthoringContext'; interface Props { sectionId: string; @@ -64,16 +64,16 @@ export const SectionSidebar = ({ sectionId }: Props) => { const intl = useIntl(); const [tab, setTab] = useState<'info' | 'settings'>('info'); const { data: sectionData, isLoading } = useCourseItemData(sectionId); - const { openPublishModal } = useCourseAuthoringContext(); + const { openPublishModal } = useCourseAuthoringContext(); const handlePublish = () => { if (sectionData?.hasChanges) { openPublishModal({ value: sectionData, sectionId: sectionData.id, - }) + }); } - } + }; if (isLoading) { return ; @@ -103,4 +103,4 @@ export const SectionSidebar = ({ sectionId }: Props) => {
); -} +}; diff --git a/src/course-outline/outline-sidebar/SubsectionInfoSidebar.tsx b/src/course-outline/outline-sidebar/SubsectionInfoSidebar.tsx index 38fb609a2a..bfd0ca82d2 100644 --- a/src/course-outline/outline-sidebar/SubsectionInfoSidebar.tsx +++ b/src/course-outline/outline-sidebar/SubsectionInfoSidebar.tsx @@ -9,13 +9,13 @@ import { useGetBlockTypes } from '@src/search-manager'; import { SidebarContent, SidebarSection, SidebarTitle } from '@src/generic/sidebar'; -import messages from './messages'; import { useCourseItemData } from '@src/course-outline/data/apiHooks'; import Loading from '@src/generic/Loading'; -import { LibraryReferenceCard } from './LibraryReferenceCard'; -import { PublishButon } from './PublishButon'; import { useCourseAuthoringContext } from '@src/CourseAuthoringContext'; import { useOutlineSidebarContext } from '@src/course-outline/outline-sidebar/OutlineSidebarContext'; +import { LibraryReferenceCard } from './LibraryReferenceCard'; +import { PublishButon } from './PublishButon'; +import messages from './messages'; interface Props { subsectionId: string; @@ -66,16 +66,16 @@ export const SubsectionSidebar = ({ subsectionId }: Props) => { const [tab, setTab] = useState<'info' | 'settings'>('info'); const { data: subsectionData, isLoading } = useCourseItemData(subsectionId); const { selectedContainerState } = useOutlineSidebarContext(); - const { openPublishModal } = useCourseAuthoringContext(); + const { openPublishModal } = useCourseAuthoringContext(); const handlePublish = () => { if (selectedContainerState?.sectionId && subsectionData?.hasChanges) { openPublishModal({ value: subsectionData, sectionId: selectedContainerState?.sectionId, - }) + }); } - } + }; if (isLoading) { return ; @@ -105,4 +105,4 @@ export const SubsectionSidebar = ({ subsectionId }: Props) => {
); -} +}; diff --git a/src/course-outline/outline-sidebar/UnitInfoSidebar.tsx b/src/course-outline/outline-sidebar/UnitInfoSidebar.tsx index 4324be6a8e..c65295d60c 100644 --- a/src/course-outline/outline-sidebar/UnitInfoSidebar.tsx +++ b/src/course-outline/outline-sidebar/UnitInfoSidebar.tsx @@ -1,7 +1,11 @@ import { useState } from 'react'; import { useIntl } from '@edx/frontend-platform/i18n'; -import { Button, Stack, Tab, Tabs, useToggle } from '@openedx/paragon'; -import { Expand, OpenInFull, SchoolOutline, Tag } from '@openedx/paragon/icons'; +import { + Button, Stack, Tab, Tabs, useToggle, +} from '@openedx/paragon'; +import { + OpenInFull, SchoolOutline, Tag, +} from '@openedx/paragon/icons'; import { ContentTagsDrawerSheet, ContentTagsSnippet } from '@src/content-tags-drawer'; import { ComponentCountSnippet } from '@src/generic/block-type-utils'; @@ -9,16 +13,16 @@ import { useGetBlockTypes } from '@src/search-manager'; import { SidebarContent, SidebarSection, SidebarTitle } from '@src/generic/sidebar'; -import messages from './messages'; import { useCourseItemData } from '@src/course-outline/data/apiHooks'; import Loading from '@src/generic/Loading'; -import { LibraryReferenceCard } from './LibraryReferenceCard'; -import { PublishButon } from './PublishButon'; import { useCourseAuthoringContext } from '@src/CourseAuthoringContext'; -import { useOutlineSidebarContext } from './OutlineSidebarContext'; import XBlockContainerIframe from '@src/course-unit/xblock-container-iframe'; import { IframeProvider } from '@src/generic/hooks/context/iFrameContext'; import { Link } from 'react-router-dom'; +import { useOutlineSidebarContext } from './OutlineSidebarContext'; +import { PublishButon } from './PublishButon'; +import { LibraryReferenceCard } from './LibraryReferenceCard'; +import messages from './messages'; interface Props { unitId: string; @@ -66,7 +70,7 @@ const UnitInfoSidebar = ({ unitId }: Props) => { export const UnitSidebar = ({ unitId }: Props) => { const intl = useIntl(); - const [tab, setTab] = useState<'preview'| 'info' | 'settings'>('info'); + const [tab, setTab] = useState<'preview' | 'info' | 'settings'>('info'); const { data: unitData, isLoading } = useCourseItemData(unitId); const { selectedContainerState } = useOutlineSidebarContext(); const { openPublishModal, getUnitUrl, courseId } = useCourseAuthoringContext(); @@ -76,9 +80,9 @@ export const UnitSidebar = ({ unitId }: Props) => { openPublishModal({ value: unitData, sectionId: selectedContainerState?.sectionId, - }) + }); } - } + }; if (isLoading) { return ; @@ -90,7 +94,7 @@ export const UnitSidebar = ({ unitId }: Props) => { title={unitData?.displayName || ''} icon={SchoolOutline} /> - +
diff --git a/src/course-outline/subsection-card/SubsectionCard.test.tsx b/src/course-outline/subsection-card/SubsectionCard.test.tsx index ec25456fbb..844a7d4f0d 100644 --- a/src/course-outline/subsection-card/SubsectionCard.test.tsx +++ b/src/course-outline/subsection-card/SubsectionCard.test.tsx @@ -440,7 +440,7 @@ describe('', () => { isOpen: true, open: jest.fn(), toggle: jest.fn(), - currentFlow: null, + currentFlow: undefined, startCurrentFlow: jest.fn(), stopCurrentFlow: jest.fn(), openContainerInfoSidebar: jest.fn(), diff --git a/src/course-outline/subsection-card/SubsectionCard.tsx b/src/course-outline/subsection-card/SubsectionCard.tsx index 1829fca393..3533f15c35 100644 --- a/src/course-outline/subsection-card/SubsectionCard.tsx +++ b/src/course-outline/subsection-card/SubsectionCard.tsx @@ -324,7 +324,7 @@ const SubsectionCard = ({ onClickCard={(e) => onClickCard(e, true)} childType={ContainerType.Unit} parentLocator={subsection.id} - parentTitle={subsection.displayName} + grandParentLocator={section.id} /> {enableCopyPasteUnits && showPasteUnit && sharedClipboardData && ( ', () => { isOpen: true, open: jest.fn(), toggle: jest.fn(), - currentFlow: null, + currentFlow: undefined, startCurrentFlow: jest.fn(), stopCurrentFlow: jest.fn(), openContainerInfoSidebar: jest.fn(), diff --git a/src/generic/sidebar/SidebarTitle.tsx b/src/generic/sidebar/SidebarTitle.tsx index 93b29c373b..00e9b61c73 100644 --- a/src/generic/sidebar/SidebarTitle.tsx +++ b/src/generic/sidebar/SidebarTitle.tsx @@ -1,10 +1,12 @@ -import { Icon, Stack } from '@openedx/paragon'; +import { Icon, IconButton, Stack } from '@openedx/paragon'; +import { ArrowBack } from '@openedx/paragon/icons'; interface SidebarTitleProps { /** Title of the section */ title: string; /** Icon to be displayed in the section title */ icon?: React.ComponentType; + onBackBtnClick?: () => void; } /** @@ -16,8 +18,20 @@ interface SidebarTitleProps { * This is meant to standardize the look and feel of the sidebar section titles, * so that it can be reused across different parts of the application. */ -export const SidebarTitle = ({ title, icon }: SidebarTitleProps) => ( +export const SidebarTitle = ({ + title, + icon, + onBackBtnClick, +}: SidebarTitleProps) => ( + {onBackBtnClick && ( + + )}

{title}

From f19c2c57213f8ce2813ff83dbb5b717d6705980a Mon Sep 17 00:00:00 2001 From: Navin Karkera Date: Thu, 29 Jan 2026 17:52:06 +0530 Subject: [PATCH 27/60] fix: sync issues in unlink, publish and general updates --- src/CourseAuthoringContext.tsx | 3 ++- src/course-outline/data/apiHooks.ts | 3 +-- src/course-outline/hooks.jsx | 17 +++++++++++++++++ .../outline-sidebar/AddSidebar.tsx | 14 +++++++++++--- .../outline-sidebar/UnitInfoSidebar.tsx | 3 ++- .../publish-modal/PublishModal.tsx | 4 +++- .../section-card/SectionCard.tsx | 7 +++++-- .../subsection-card/SubsectionCard.tsx | 8 ++++++-- src/course-outline/unit-card/UnitCard.tsx | 19 +++++++++++++++---- src/data/types.ts | 1 + src/generic/unlink-modal/data/apiHooks.ts | 2 +- 11 files changed, 64 insertions(+), 17 deletions(-) diff --git a/src/CourseAuthoringContext.tsx b/src/CourseAuthoringContext.tsx index 8e80e8c63f..1c575e1a23 100644 --- a/src/CourseAuthoringContext.tsx +++ b/src/CourseAuthoringContext.tsx @@ -18,7 +18,8 @@ import { RequestStatus, RequestStatusType } from './data/constants'; type ModalState = { value: XBlock; - sectionId: string; + subsectionId?: string; + sectionId?: string; }; export type CourseAuthoringContextData = { diff --git a/src/course-outline/data/apiHooks.ts b/src/course-outline/data/apiHooks.ts index cb04c85423..d05b02f89d 100644 --- a/src/course-outline/data/apiHooks.ts +++ b/src/course-outline/data/apiHooks.ts @@ -77,13 +77,12 @@ export const useUpdateCourseBlockName = (courseId: string) => { }); }; -export const usePublishCourseItem = (sectionId?: string) => { +export const usePublishCourseItem = () => { const queryClient = useQueryClient(); return useMutation({ mutationFn: publishCourseItem, onSettled: async (_data, _err, itemId) => { queryClient.invalidateQueries({ queryKey: courseOutlineQueryKeys.courseItemId(itemId) }); - queryClient.invalidateQueries({ queryKey: courseOutlineQueryKeys.courseItemId(sectionId) }); }, }); }; diff --git a/src/course-outline/hooks.jsx b/src/course-outline/hooks.jsx index 482dfc5548..fdeace4a01 100644 --- a/src/course-outline/hooks.jsx +++ b/src/course-outline/hooks.jsx @@ -51,6 +51,8 @@ import { dismissNotificationQuery, syncDiscussionsTopics, } from './data/thunk'; +import { useQueryClient } from '@tanstack/react-query'; +import { courseOutlineQueryKeys } from '@src/course-outline/data/apiHooks'; const useCourseOutline = ({ courseId }) => { const dispatch = useDispatch(); @@ -93,6 +95,7 @@ const useCourseOutline = ({ courseId }) => { const [isHighlightsModalOpen, openHighlightsModal, closeHighlightsModal] = useToggle(false); const [isConfigureModalOpen, openConfigureModal, closeConfigureModal] = useToggle(false); const [isDeleteModalOpen, openDeleteModal, closeDeleteModal] = useToggle(false); + const queryClient = useQueryClient(); const isSavingStatusFailed = savingStatus === RequestStatus.FAILED || genericSavingStatus === RequestStatus.FAILED; @@ -169,6 +172,20 @@ const useCourseOutline = ({ courseId }) => { await unlinkDownstream(currentUnlinkModalData.value.id, { onSuccess: () => { closeUnlinkModal(); + // refresh child block data + currentUnlinkModalData.value.childInfo.children.forEach((block) => { + queryClient.invalidateQueries({ queryKey: courseOutlineQueryKeys.courseItemId(block.id) }); + block.childInfo?.children.forEach(({ id: blockId }) => { + queryClient.invalidateQueries({ queryKey: courseOutlineQueryKeys.courseItemId(blockId) }); + }); + }); + // refresh parent blocks data + queryClient.invalidateQueries({ + queryKey: courseOutlineQueryKeys.courseItemId(currentUnlinkModalData?.sectionId), + }); + queryClient.invalidateQueries({ + queryKey: courseOutlineQueryKeys.courseItemId(currentUnlinkModalData?.subsectionId), + }); }, }); }, [currentUnlinkModalData, unlinkDownstream, closeUnlinkModal]); diff --git a/src/course-outline/outline-sidebar/AddSidebar.tsx b/src/course-outline/outline-sidebar/AddSidebar.tsx index d2fe3c3cb3..1e88716101 100644 --- a/src/course-outline/outline-sidebar/AddSidebar.tsx +++ b/src/course-outline/outline-sidebar/AddSidebar.tsx @@ -65,7 +65,7 @@ const AddContentButton = ({ name, blockType } : AddContentButtonProps) => { } = useOutlineSidebarContext(); const queryClient = useQueryClient(); let sectionParentId = lastEditableSection?.id; - let subsectionParentId = lastEditableSubsection?.data.id; + let subsectionParentId = lastEditableSubsection?.data?.id; const onCreateContent = useCallback(async () => { switch (blockType) { @@ -228,7 +228,7 @@ const ShowLibraryContent = () => { const queryClient = useQueryClient(); let sectionParentId = lastEditableSection?.id; - let subsectionParentId = lastEditableSubsection?.data.id; + let subsectionParentId = lastEditableSubsection?.data?.id; const onComponentSelected: ComponentSelectedEvent = useCallback(async ({ usageKey, blockType }) => { switch (blockType) { @@ -366,7 +366,15 @@ export const AddSidebar = () => { return { title: currentItemData.displayName, icon: getItemIcon(currentItemData.category) }; } return { title: courseDetails?.name || '', icon: SchoolOutline }; - }, [isCurrentFlowOn, flowData, currentFlow, intl, getItemIcon]); + }, [ + isCurrentFlowOn, + flowData, + currentFlow, + intl, + getItemIcon, + currentItemData, + courseDetails, + ]); const handleBack = () => { clearSelection(); diff --git a/src/course-outline/outline-sidebar/UnitInfoSidebar.tsx b/src/course-outline/outline-sidebar/UnitInfoSidebar.tsx index a694e5944f..d43b489c75 100644 --- a/src/course-outline/outline-sidebar/UnitInfoSidebar.tsx +++ b/src/course-outline/outline-sidebar/UnitInfoSidebar.tsx @@ -76,10 +76,11 @@ export const UnitSidebar = ({ unitId }: Props) => { const { openPublishModal, getUnitUrl, courseId } = useCourseAuthoringContext(); const handlePublish = () => { - if (selectedContainerState?.sectionId && unitData?.hasChanges) { + if (unitData?.hasChanges) { openPublishModal({ value: unitData, sectionId: selectedContainerState?.sectionId, + subsectionId: selectedContainerState?.subsectionId }); } }; diff --git a/src/course-outline/publish-modal/PublishModal.tsx b/src/course-outline/publish-modal/PublishModal.tsx index 360d5be7c4..780ab81980 100644 --- a/src/course-outline/publish-modal/PublishModal.tsx +++ b/src/course-outline/publish-modal/PublishModal.tsx @@ -22,7 +22,7 @@ const PublishModal = () => { } = currentPublishModalData?.value || {}; const categoryName = COURSE_BLOCK_NAMES[category]?.name.toLowerCase(); const children: XBlock[] = childInfo?.children || []; - const publishMutation = usePublishCourseItem(currentPublishModalData?.sectionId); + const publishMutation = usePublishCourseItem(); const queryClient = useQueryClient(); const childrenIds = useMemo(() => children.reduce(( @@ -46,6 +46,8 @@ const PublishModal = () => { childrenIds.forEach((blockId) => { queryClient.invalidateQueries({ queryKey: courseOutlineQueryKeys.courseItemId(blockId) }); }); + queryClient.invalidateQueries({ queryKey: courseOutlineQueryKeys.courseItemId(currentPublishModalData?.sectionId) }); + queryClient.invalidateQueries({ queryKey: courseOutlineQueryKeys.courseItemId(currentPublishModalData?.subsectionId) }); }, }); } diff --git a/src/course-outline/section-card/SectionCard.tsx b/src/course-outline/section-card/SectionCard.tsx index 102fe7180c..5f8f278119 100644 --- a/src/course-outline/section-card/SectionCard.tsx +++ b/src/course-outline/section-card/SectionCard.tsx @@ -26,6 +26,7 @@ import { useCourseAuthoringContext } from '@src/CourseAuthoringContext'; import { useOutlineSidebarContext } from '@src/course-outline/outline-sidebar/OutlineSidebarContext'; import { courseOutlineQueryKeys, useCourseItemData } from '@src/course-outline/data/apiHooks'; import messages from './messages'; +import moment from 'moment'; interface SectionCardProps { section: XBlock, @@ -108,8 +109,10 @@ const SectionCard = ({ /** Temporary measure to keep the react-query state updated with redux state */ useEffect(() => { - queryClient.setQueryData(courseOutlineQueryKeys.courseItemId(initialData.id), initialData); - }, [initialData]); + if (moment(initialData.editedOnRaw).isAfter(moment(section.editedOnRaw))) { + queryClient.setQueryData(courseOutlineQueryKeys.courseItemId(initialData.id), initialData); + } + }, [initialData, section]); const { id, diff --git a/src/course-outline/subsection-card/SubsectionCard.tsx b/src/course-outline/subsection-card/SubsectionCard.tsx index 3533f15c35..f4bb6737fd 100644 --- a/src/course-outline/subsection-card/SubsectionCard.tsx +++ b/src/course-outline/subsection-card/SubsectionCard.tsx @@ -28,6 +28,8 @@ import { useCourseAuthoringContext } from '@src/CourseAuthoringContext'; import { useOutlineSidebarContext } from '@src/course-outline/outline-sidebar/OutlineSidebarContext'; import { courseOutlineQueryKeys, useCourseItemData } from '@src/course-outline/data/apiHooks'; import messages from './messages'; +import { sub } from 'date-fns'; +import moment from 'moment'; interface SubsectionCardProps { section: XBlock, @@ -143,8 +145,10 @@ const SubsectionCard = ({ /** Temporary measure to keep the react-query state updated with redux state */ useEffect(() => { - queryClient.setQueryData(courseOutlineQueryKeys.courseItemId(initialData.id), initialData); - }, [initialData]); + if (moment(initialData.editedOnRaw).isAfter(moment(subsection.editedOnRaw))) { + queryClient.setQueryData(courseOutlineQueryKeys.courseItemId(initialData.id), initialData); + } + }, [initialData, subsection]); const handleExpandContent = () => { setIsExpanded((prevState) => !prevState); diff --git a/src/course-outline/unit-card/UnitCard.tsx b/src/course-outline/unit-card/UnitCard.tsx index 17901bd66d..aeb01c8772 100644 --- a/src/course-outline/unit-card/UnitCard.tsx +++ b/src/course-outline/unit-card/UnitCard.tsx @@ -26,6 +26,7 @@ import type { XBlock } from '@src/data/types'; import { useCourseAuthoringContext } from '@src/CourseAuthoringContext'; import { courseOutlineQueryKeys, useCourseItemData } from '@src/course-outline/data/apiHooks'; import { useOutlineSidebarContext } from '../outline-sidebar/OutlineSidebarContext'; +import moment from 'moment'; interface UnitCardProps { unit: XBlock; @@ -191,8 +192,10 @@ const UnitCard = ({ /** Temporary measure to keep the react-query state updated with redux state */ useEffect(() => { - queryClient.setQueryData(courseOutlineQueryKeys.courseItemId(initialData.id), initialData); - }, [initialData]); + if (moment(initialData.editedOnRaw).isAfter(moment(unit.editedOnRaw))) { + queryClient.setQueryData(courseOutlineQueryKeys.courseItemId(initialData.id), initialData); + } + }, [initialData, unit]); useEffect(() => { // if this items has been newly added, scroll to it. @@ -248,10 +251,18 @@ const UnitCard = ({ hasChanges={hasChanges} cardId={id} onClickMenuButton={selectAndTrigger} - onClickPublish={() => openPublishModal({ value: unit, sectionId: section.id })} + onClickPublish={() => openPublishModal({ + value: unit, + sectionId: section.id, + subsectionId: subsection.id, + })} onClickConfigure={onOpenConfigureModal} onClickDelete={onOpenDeleteModal} - onClickUnlink={() => openUnlinkModal({ value: unit, sectionId: section.id })} + onClickUnlink={() => openUnlinkModal({ + value: unit, + sectionId: section.id, + subsectionId: subsection.id, + })} onClickMoveUp={handleUnitMoveUp} onClickMoveDown={handleUnitMoveDown} onClickSync={openSyncModal} diff --git a/src/data/types.ts b/src/data/types.ts index f4484e3aa4..8adfc16997 100644 --- a/src/data/types.ts +++ b/src/data/types.ts @@ -78,6 +78,7 @@ export interface XBlock { category: string; hasChildren: boolean; editedOn: string; + editedOnRaw: string; published: boolean; publishedOn: string; studioUrl: string; diff --git a/src/generic/unlink-modal/data/apiHooks.ts b/src/generic/unlink-modal/data/apiHooks.ts index f1af9b4319..02b7b21d95 100644 --- a/src/generic/unlink-modal/data/apiHooks.ts +++ b/src/generic/unlink-modal/data/apiHooks.ts @@ -15,7 +15,7 @@ export const useUnlinkDownstream = () => { queryKey: courseLibrariesQueryKeys.courseLibraries(courseKey), }); queryClient.invalidateQueries({ - queryKey: courseOutlineQueryKeys.course(courseKey), + queryKey: courseOutlineQueryKeys.courseItemId(contentId), }); }, }); From bc9ab4d601b372719b8ad3e0e450ba4a279359cc Mon Sep 17 00:00:00 2001 From: Navin Karkera Date: Thu, 29 Jan 2026 21:41:59 +0530 Subject: [PATCH 28/60] fix: course outline status bar unpublished badge sync --- src/course-outline/data/apiHooks.ts | 5 +++ src/course-outline/data/slice.ts | 1 - src/course-outline/data/types.ts | 2 +- .../publish-modal/PublishModal.tsx | 2 +- src/course-outline/status-bar/StatusBar.tsx | 32 +++++++++++-------- src/generic/unlink-modal/data/apiHooks.ts | 3 ++ 6 files changed, 29 insertions(+), 16 deletions(-) diff --git a/src/course-outline/data/apiHooks.ts b/src/course-outline/data/apiHooks.ts index d05b02f89d..57d59fae58 100644 --- a/src/course-outline/data/apiHooks.ts +++ b/src/course-outline/data/apiHooks.ts @@ -47,6 +47,9 @@ export const useCreateCourseBlock = ( onSettled: async (data: { locator: string; }, _err, variables) => { await callback?.(data.locator, variables.parentLocator); queryClient.invalidateQueries({ queryKey: courseOutlineQueryKeys.courseItemId(variables.parentLocator) }); + queryClient.invalidateQueries({ + queryKey: courseOutlineQueryKeys.courseDetails(getCourseKey(data.locator)), + }); }, }); }; @@ -72,6 +75,7 @@ export const useUpdateCourseBlockName = (courseId: string) => { mutationFn: editItemDisplayName, onSettled: async (_data, _err, variables) => { queryClient.invalidateQueries({ queryKey: containerComparisonQueryKeys.course(courseId) }); + queryClient.invalidateQueries({ queryKey: courseOutlineQueryKeys.courseDetails(courseId) }); queryClient.invalidateQueries({ queryKey: courseOutlineQueryKeys.courseItemId(variables.itemId) }); }, }); @@ -83,6 +87,7 @@ export const usePublishCourseItem = () => { mutationFn: publishCourseItem, onSettled: async (_data, _err, itemId) => { queryClient.invalidateQueries({ queryKey: courseOutlineQueryKeys.courseItemId(itemId) }); + queryClient.invalidateQueries({ queryKey: courseOutlineQueryKeys.courseDetails(getCourseKey(itemId)) }); }, }); }; diff --git a/src/course-outline/data/slice.ts b/src/course-outline/data/slice.ts index 56a67f7e7b..412346bd26 100644 --- a/src/course-outline/data/slice.ts +++ b/src/course-outline/data/slice.ts @@ -33,7 +33,6 @@ const initialState = { }, videoSharingEnabled: false, videoSharingOptions: VIDEO_SHARING_OPTIONS.perVideo, - hasChanges: false, }, sectionsList: [], isCustomRelativeDatesActive: false, diff --git a/src/course-outline/data/types.ts b/src/course-outline/data/types.ts index b141b7d792..55323104fc 100644 --- a/src/course-outline/data/types.ts +++ b/src/course-outline/data/types.ts @@ -33,6 +33,7 @@ export interface CourseDetails { subtitle?: string; org: string; description?: string; + hasChanges: boolean; } export interface ChecklistType { @@ -50,7 +51,6 @@ export interface CourseOutlineStatusBar { checklist: ChecklistType; videoSharingEnabled: boolean; videoSharingOptions: string; - hasChanges: boolean; } export interface CourseOutlineState { diff --git a/src/course-outline/publish-modal/PublishModal.tsx b/src/course-outline/publish-modal/PublishModal.tsx index 780ab81980..83a7292736 100644 --- a/src/course-outline/publish-modal/PublishModal.tsx +++ b/src/course-outline/publish-modal/PublishModal.tsx @@ -20,7 +20,7 @@ const PublishModal = () => { const { id, displayName, childInfo, category, } = currentPublishModalData?.value || {}; - const categoryName = COURSE_BLOCK_NAMES[category]?.name.toLowerCase(); + const categoryName = COURSE_BLOCK_NAMES[category || '']?.name.toLowerCase(); const children: XBlock[] = childInfo?.children || []; const publishMutation = usePublishCourseItem(); const queryClient = useQueryClient(); diff --git a/src/course-outline/status-bar/StatusBar.tsx b/src/course-outline/status-bar/StatusBar.tsx index 13b458f99f..93341d587d 100644 --- a/src/course-outline/status-bar/StatusBar.tsx +++ b/src/course-outline/status-bar/StatusBar.tsx @@ -12,6 +12,7 @@ import { useWaffleFlags } from '@src/data/apiHooks'; import { useEntityLinksSummaryByDownstreamContext } from '@src/course-libraries/data/apiHooks'; import messages from './messages'; import { NotificationStatusIcon } from './NotificationStatusIcon'; +import { useCourseDetails } from '@src/course-outline/data/apiHooks'; const CourseBadge = ({ startDate, endDate }: { startDate: Moment, endDate: Moment }) => { const now = moment().utc(); @@ -42,17 +43,23 @@ const CourseBadge = ({ startDate, endDate }: { startDate: Moment, endDate: Momen } }; -const UnpublishedBadgeStatus = () => ( - - - - - - -); +const UnpublishedBadgeStatus = ({ courseId }: { courseId: string }) => { + const { data } = useCourseDetails(courseId); + if (!data?.hasChanges) { + return null; + } + return ( + + + + + + + ) +}; const LibraryUpdates = ({ courseId }: { courseId: string }) => { const { data } = useEntityLinksSummaryByDownstreamContext(courseId); @@ -178,7 +185,6 @@ export const StatusBar = ({ endDate, courseReleaseDate, checklist, - hasChanges, } = statusBarData; const courseReleaseDateObj = moment.utc(courseReleaseDate, 'MMM DD, YYYY [at] HH:mm UTC', true); @@ -192,7 +198,7 @@ export const StatusBar = ({ return ( - {hasChanges && } + { queryClient.invalidateQueries({ queryKey: courseOutlineQueryKeys.courseItemId(contentId), }); + queryClient.invalidateQueries({ + queryKey: courseOutlineQueryKeys.courseDetails(courseKey), + }); }, }); }; From 48dd188eef79e5c00f517833cc2826bf1233a073 Mon Sep 17 00:00:00 2001 From: Navin Karkera Date: Thu, 29 Jan 2026 21:51:42 +0530 Subject: [PATCH 29/60] fix: lint issues --- src/CourseAuthoringContext.tsx | 4 +- src/course-outline/CourseOutline.test.tsx | 2 +- src/course-outline/CourseOutline.tsx | 2 +- src/course-outline/OutlineAddChildButtons.tsx | 2 +- src/course-outline/hooks.jsx | 6 +-- .../outline-sidebar/AddSidebar.test.tsx | 2 +- .../outline-sidebar/AddSidebar.tsx | 35 ++++++------- .../outline-sidebar/OutlineAlignSidebar.tsx | 8 ++- .../outline-sidebar/OutlineSidebarContext.tsx | 19 ++++--- .../outline-sidebar/SectionInfoSidebar.tsx | 2 +- .../outline-sidebar/UnitInfoSidebar.tsx | 2 +- .../publish-modal/PublishModal.tsx | 8 ++- .../section-card/SectionCard.tsx | 2 +- .../status-bar/LegacyStatusBar.test.tsx | 1 - .../status-bar/StatusBar.test.tsx | 17 ++++--- src/course-outline/status-bar/StatusBar.tsx | 4 +- .../subsection-card/SubsectionCard.tsx | 3 +- src/course-outline/unit-card/UnitCard.tsx | 2 +- src/generic/resizable/Resizable.tsx | 50 ++++++++++--------- src/generic/sidebar/SidebarTitle.tsx | 2 +- .../library-filters/SidebarFilters.tsx | 4 +- 21 files changed, 92 insertions(+), 85 deletions(-) diff --git a/src/CourseAuthoringContext.tsx b/src/CourseAuthoringContext.tsx index 1c575e1a23..e5bf3fb8cf 100644 --- a/src/CourseAuthoringContext.tsx +++ b/src/CourseAuthoringContext.tsx @@ -6,7 +6,9 @@ import { getAuthenticatedUser } from '@edx/frontend-platform/auth'; import { courseOutlineQueryKeys, useCreateCourseBlock } from '@src/course-outline/data/apiHooks'; import { getCourseItem } from '@src/course-outline/data/api'; import { useDispatch, useSelector } from 'react-redux'; -import { addSection, addSubsection, addUnit, updateSavingStatus } from '@src/course-outline/data/slice'; +import { + addSection, addSubsection, addUnit, updateSavingStatus, +} from '@src/course-outline/data/slice'; import { useNavigate } from 'react-router'; import { getOutlineIndexData } from '@src/course-outline/data/selectors'; import { useToggleWithValue } from '@src/hooks'; diff --git a/src/course-outline/CourseOutline.test.tsx b/src/course-outline/CourseOutline.test.tsx index 7cfab05f0c..6d820f5d9a 100644 --- a/src/course-outline/CourseOutline.test.tsx +++ b/src/course-outline/CourseOutline.test.tsx @@ -78,7 +78,7 @@ jest.mock('@src/course-outline/outline-sidebar/OutlineSidebarContext', () => ({ useOutlineSidebarContext: () => ({ ...jest.requireActual('@src/course-outline/outline-sidebar/OutlineSidebarContext').useOutlineSidebarContext(), clearSelection, - selectedContainerState: { currentId: selectedContainerId } + selectedContainerState: { currentId: selectedContainerId }, }), })); diff --git a/src/course-outline/CourseOutline.tsx b/src/course-outline/CourseOutline.tsx index 6d58bc02ca..6708c5d6d7 100644 --- a/src/course-outline/CourseOutline.tsx +++ b/src/course-outline/CourseOutline.tsx @@ -167,7 +167,7 @@ const CourseOutline = () => { const { data: currentItemData } = useCourseItemData(currentSelection?.currentId); - const itemCategory = currentItemData?.category; + const itemCategory = currentItemData?.category || ''; const itemCategoryName = COURSE_BLOCK_NAMES[itemCategory]?.name.toLowerCase(); const enableProctoredExams = useSelector(getProctoredExamsFlag); diff --git a/src/course-outline/OutlineAddChildButtons.tsx b/src/course-outline/OutlineAddChildButtons.tsx index ed4be2645d..ef8c583b45 100644 --- a/src/course-outline/OutlineAddChildButtons.tsx +++ b/src/course-outline/OutlineAddChildButtons.tsx @@ -86,11 +86,11 @@ interface BaseProps { btnClasses?: string; btnSize?: 'sm' | 'md' | 'lg' | 'inline'; parentLocator: string; - grandParentLocator?: string; } interface NewChildButtonsProps extends BaseProps { handleUseFromLibraryClick?: () => void; + grandParentLocator?: string; } const NewOutlineAddChildButtons = ({ diff --git a/src/course-outline/hooks.jsx b/src/course-outline/hooks.jsx index fdeace4a01..27704f9231 100644 --- a/src/course-outline/hooks.jsx +++ b/src/course-outline/hooks.jsx @@ -11,6 +11,8 @@ import { useCourseAuthoringContext } from '@src/CourseAuthoringContext'; import { ContainerType, getBlockType } from '@src/generic/key-utils'; import { useOutlineSidebarContext } from '@src/course-outline/outline-sidebar/OutlineSidebarContext'; import { useUnlinkDownstream } from '@src/generic/unlink-modal'; +import { useQueryClient } from '@tanstack/react-query'; +import { courseOutlineQueryKeys } from '@src/course-outline/data/apiHooks'; import { COURSE_BLOCK_NAMES } from './constants'; import { resetScrollField, @@ -51,8 +53,6 @@ import { dismissNotificationQuery, syncDiscussionsTopics, } from './data/thunk'; -import { useQueryClient } from '@tanstack/react-query'; -import { courseOutlineQueryKeys } from '@src/course-outline/data/apiHooks'; const useCourseOutline = ({ courseId }) => { const dispatch = useDispatch(); @@ -226,7 +226,7 @@ const useCourseOutline = ({ courseId }) => { break; default: // istanbul ignore next - throw new Error(`Unrecognized category ${category}`);; + throw new Error(`Unrecognized category ${category}`); } closeDeleteModal(); if (selectedContainerState.currentId === currentSelection?.currentId) { diff --git a/src/course-outline/outline-sidebar/AddSidebar.test.tsx b/src/course-outline/outline-sidebar/AddSidebar.test.tsx index 8d8fdda4b8..fc6349ecad 100644 --- a/src/course-outline/outline-sidebar/AddSidebar.test.tsx +++ b/src/course-outline/outline-sidebar/AddSidebar.test.tsx @@ -17,8 +17,8 @@ import { OutlineSidebarProvider, } from '@src/course-outline/outline-sidebar/OutlineSidebarContext'; import fetchMock from 'fetch-mock-jest'; -import { AddSidebar } from './AddSidebar'; import type { ContainerType } from '@src/generic/key-utils'; +import { AddSidebar } from './AddSidebar'; const handleAddSection = { mutateAsync: jest.fn() }; const handleAddSubsection = { mutateAsync: jest.fn() }; diff --git a/src/course-outline/outline-sidebar/AddSidebar.tsx b/src/course-outline/outline-sidebar/AddSidebar.tsx index 1e88716101..aea32caceb 100644 --- a/src/course-outline/outline-sidebar/AddSidebar.tsx +++ b/src/course-outline/outline-sidebar/AddSidebar.tsx @@ -20,19 +20,18 @@ import { ContentType } from '@src/library-authoring/routes'; import { ComponentPicker } from '@src/library-authoring'; import { MultiLibraryProvider } from '@src/library-authoring/common/context/MultiLibraryContext'; import { COURSE_BLOCK_NAMES } from '@src/constants'; -import messages from './messages'; -import { useOutlineSidebarContext } from './OutlineSidebarContext'; import AlertMessage from '@src/generic/alert-message'; import { useQueryClient } from '@tanstack/react-query'; import { courseOutlineQueryKeys, useCourseItemData } from '@src/course-outline/data/apiHooks'; - +import { useOutlineSidebarContext } from './OutlineSidebarContext'; +import messages from './messages'; const CannotAddContentAlert = () => { const intl = useIntl(); const { currentItemData } = useOutlineSidebarContext(); return ( { icon={InfoOutline} /> ); -} +}; type AddContentButtonProps = { name: string, @@ -79,7 +78,7 @@ const AddContentButton = ({ name, blockType } : AddContentButtonProps) => { data.locator, undefined, data.locator, - ) + ), }); break; case 'subsection': @@ -94,7 +93,7 @@ const AddContentButton = ({ name, blockType } : AddContentButtonProps) => { data.locator, data.locator, sectionParentId, - ) + ), }); } break; @@ -113,7 +112,7 @@ const AddContentButton = ({ name, blockType } : AddContentButtonProps) => { queryClient.invalidateQueries({ queryKey: courseOutlineQueryKeys.courseItemId(sectionParentId), }); - } + }, }); } break; @@ -134,14 +133,12 @@ const AddContentButton = ({ name, blockType } : AddContentButtonProps) => { lastEditableSubsection, ]); - const enabled = useMemo(() => { - return ( - (currentFlow) - || (blockType === 'subsection' && lastEditableSection) + const enabled = useMemo(() => ( + (currentFlow) + || (blockType === 'subsection' && lastEditableSection) || (blockType === 'unit' && lastEditableSubsection) || (blockType === 'section' && !currentItemData) - ) - }, [ + ), [ currentFlow, blockType, currentItemData, @@ -198,7 +195,7 @@ const AddNewContent = () => { }, [currentFlow, intl]); if (!isCurrentFlowOn && currentItemData && !currentItemData.actions.childAddable) { - return + return ; } return ( @@ -267,7 +264,7 @@ const ShowLibraryContent = () => { queryClient.invalidateQueries({ queryKey: courseOutlineQueryKeys.courseItemId(sectionParentId), }); - } + }, }); } break; @@ -290,7 +287,7 @@ const ShowLibraryContent = () => { const allowedBlocks = useMemo(() => { const blocks: ContainerType[] = []; if (currentFlow?.flowType) { - return [currentFlow.flowType]; + return [currentFlow.flowType]; } if (!selectedContainerState) { blocks.push(ContainerType.Section); } if (lastEditableSection) { blocks.push(ContainerType.Subsection); } @@ -299,7 +296,7 @@ const ShowLibraryContent = () => { }, [lastEditableSection, lastEditableSubsection, currentFlow]); if (!isCurrentFlowOn && currentItemData && !currentItemData.actions.childAddable) { - return + return ; } return ( @@ -379,7 +376,7 @@ export const AddSidebar = () => { const handleBack = () => { clearSelection(); stopCurrentFlow(); - } + }; return (
diff --git a/src/course-outline/outline-sidebar/OutlineAlignSidebar.tsx b/src/course-outline/outline-sidebar/OutlineAlignSidebar.tsx index ca4fa9dbd6..cb53536cbd 100644 --- a/src/course-outline/outline-sidebar/OutlineAlignSidebar.tsx +++ b/src/course-outline/outline-sidebar/OutlineAlignSidebar.tsx @@ -14,9 +14,7 @@ export const OutlineAlignSidebar = () => { } = useCourseAuthoringContext(); const { selectedContainerState, clearSelection } = useOutlineSidebarContext(); - const sidebarContentId = useMemo(() => { - return currentSelection?.currentId || selectedContainerState?.currentId || courseId - }, [ + const sidebarContentId = useMemo(() => currentSelection?.currentId || selectedContainerState?.currentId || courseId, [ currentSelection, selectedContainerState, courseId, @@ -27,7 +25,7 @@ export const OutlineAlignSidebar = () => { const handleBack = () => { clearSelection(); setCurrentSelection(undefined); - } + }; return (
@@ -38,7 +36,7 @@ export const OutlineAlignSidebar = () => { : contentData?.courseDisplayNameWithDefault || '' } icon={SchoolOutline} - onBackBtnClick={(sidebarContentId !== courseId) ? handleBack: undefined} + onBackBtnClick={(sidebarContentId !== courseId) ? handleBack : undefined} /> (undefined); const getLastEditableItem = (blockList: Array) => { - let lastIndex = findLastIndex(blockList, (item) => item.actions.childAddable); + const lastIndex = findLastIndex(blockList, (item) => item.actions.childAddable); return blockList[lastIndex]; }; const getLastEditableSubsection = ( blockList: Array, - startIndex?: number + startIndex?: number, ): { data: XBlock, sectionId: string } | undefined => { - let lastSectionIndex = findLastIndex(blockList, (item) => item.actions.childAddable, startIndex); + const lastSectionIndex = findLastIndex(blockList, (item) => item.actions.childAddable, startIndex); if (lastSectionIndex !== -1) { - let lastSubsectionIndex = findLastIndex(blockList[lastSectionIndex].childInfo.children, (item) => item.actions.childAddable); + const lastSubsectionIndex = findLastIndex( + blockList[lastSectionIndex].childInfo.children, + (item) => item.actions.childAddable, + ); if (lastSubsectionIndex !== -1) { return { data: blockList[lastSectionIndex].childInfo.children[lastSubsectionIndex], sectionId: blockList[lastSectionIndex].id, - } + }; } if (lastSectionIndex > 0) { return getLastEditableSubsection(blockList, lastSectionIndex - 1); @@ -151,7 +154,7 @@ export const OutlineSidebarProvider = ({ children }: { children?: React.ReactNod if (currentItemData?.category === 'chapter' && currentItemData.actions.childAddable) { return currentItemData; } - return currentItemData ? undefined: getLastEditableItem(sectionsList) + return currentItemData ? undefined : getLastEditableItem(sectionsList); }, [currentItemData, sectionsList]); /** Stores last subsection that allows adding units inside it. */ @@ -165,7 +168,7 @@ export const OutlineSidebarProvider = ({ children }: { children?: React.ReactNod sectionId: selectedContainerState?.currentId, }; } - return currentItemData ? undefined: getLastEditableSubsection(sectionsList); + return currentItemData ? undefined : getLastEditableSubsection(sectionsList); }, [currentItemData, sectionsList]); useEscapeClick({ diff --git a/src/course-outline/outline-sidebar/SectionInfoSidebar.tsx b/src/course-outline/outline-sidebar/SectionInfoSidebar.tsx index 9cc8333618..71e0edd583 100644 --- a/src/course-outline/outline-sidebar/SectionInfoSidebar.tsx +++ b/src/course-outline/outline-sidebar/SectionInfoSidebar.tsx @@ -12,10 +12,10 @@ import { SidebarContent, SidebarSection, SidebarTitle } from '@src/generic/sideb import { useCourseItemData } from '@src/course-outline/data/apiHooks'; import Loading from '@src/generic/Loading'; import { useCourseAuthoringContext } from '@src/CourseAuthoringContext'; +import { useOutlineSidebarContext } from '@src/course-outline/outline-sidebar/OutlineSidebarContext'; import messages from './messages'; import { LibraryReferenceCard } from './LibraryReferenceCard'; import { PublishButon } from './PublishButon'; -import { useOutlineSidebarContext } from '@src/course-outline/outline-sidebar/OutlineSidebarContext'; interface Props { sectionId: string; diff --git a/src/course-outline/outline-sidebar/UnitInfoSidebar.tsx b/src/course-outline/outline-sidebar/UnitInfoSidebar.tsx index d43b489c75..6cf8c9822b 100644 --- a/src/course-outline/outline-sidebar/UnitInfoSidebar.tsx +++ b/src/course-outline/outline-sidebar/UnitInfoSidebar.tsx @@ -80,7 +80,7 @@ export const UnitSidebar = ({ unitId }: Props) => { openPublishModal({ value: unitData, sectionId: selectedContainerState?.sectionId, - subsectionId: selectedContainerState?.subsectionId + subsectionId: selectedContainerState?.subsectionId, }); } }; diff --git a/src/course-outline/publish-modal/PublishModal.tsx b/src/course-outline/publish-modal/PublishModal.tsx index 83a7292736..dd91e3dbc5 100644 --- a/src/course-outline/publish-modal/PublishModal.tsx +++ b/src/course-outline/publish-modal/PublishModal.tsx @@ -46,8 +46,12 @@ const PublishModal = () => { childrenIds.forEach((blockId) => { queryClient.invalidateQueries({ queryKey: courseOutlineQueryKeys.courseItemId(blockId) }); }); - queryClient.invalidateQueries({ queryKey: courseOutlineQueryKeys.courseItemId(currentPublishModalData?.sectionId) }); - queryClient.invalidateQueries({ queryKey: courseOutlineQueryKeys.courseItemId(currentPublishModalData?.subsectionId) }); + queryClient.invalidateQueries({ + queryKey: courseOutlineQueryKeys.courseItemId(currentPublishModalData?.sectionId), + }); + queryClient.invalidateQueries({ + queryKey: courseOutlineQueryKeys.courseItemId(currentPublishModalData?.subsectionId), + }); }, }); } diff --git a/src/course-outline/section-card/SectionCard.tsx b/src/course-outline/section-card/SectionCard.tsx index 5f8f278119..9762fa12e6 100644 --- a/src/course-outline/section-card/SectionCard.tsx +++ b/src/course-outline/section-card/SectionCard.tsx @@ -25,8 +25,8 @@ import { invalidateLinksQuery } from '@src/course-libraries/data/apiHooks'; import { useCourseAuthoringContext } from '@src/CourseAuthoringContext'; import { useOutlineSidebarContext } from '@src/course-outline/outline-sidebar/OutlineSidebarContext'; import { courseOutlineQueryKeys, useCourseItemData } from '@src/course-outline/data/apiHooks'; -import messages from './messages'; import moment from 'moment'; +import messages from './messages'; interface SectionCardProps { section: XBlock, diff --git a/src/course-outline/status-bar/LegacyStatusBar.test.tsx b/src/course-outline/status-bar/LegacyStatusBar.test.tsx index 1aa12d7a77..e6669d7f31 100644 --- a/src/course-outline/status-bar/LegacyStatusBar.test.tsx +++ b/src/course-outline/status-bar/LegacyStatusBar.test.tsx @@ -50,7 +50,6 @@ const statusBarData: CourseOutlineStatusBar = { highlightsEnabledForMessaging: true, videoSharingEnabled: true, videoSharingOptions: VIDEO_SHARING_OPTIONS.allOn, - hasChanges: true, }; const queryClient = new QueryClient(); diff --git a/src/course-outline/status-bar/StatusBar.test.tsx b/src/course-outline/status-bar/StatusBar.test.tsx index 4bd9672226..8857de48bb 100644 --- a/src/course-outline/status-bar/StatusBar.test.tsx +++ b/src/course-outline/status-bar/StatusBar.test.tsx @@ -20,7 +20,6 @@ const statusBarData: CourseOutlineStatusBar = { highlightsEnabledForMessaging: true, videoSharingEnabled: true, videoSharingOptions: VIDEO_SHARING_OPTIONS.allOn, - hasChanges: false, }; jest.mock('@src/course-libraries/data/apiHooks', () => ({ @@ -30,6 +29,14 @@ jest.mock('@src/course-libraries/data/apiHooks', () => ({ }), })); +let mockHasChanges = false; +jest.mock('@src/course-outline/data/apiHooks', () => ({ + useCourseDetails: () => ({ + data: [{ hasChanges: mockHasChanges }], + isLoading: false, + }), +})); + const renderComponent = (props?: Partial) => render( ', () => { }); it('renders unpublished badge', async () => { - renderComponent({ - statusBarData: { - ...statusBarData, - hasChanges: true, - }, - }); + mockHasChanges = true; + renderComponent(); expect(await screen.findByText('Unpublished Changes')).toBeInTheDocument(); }); diff --git a/src/course-outline/status-bar/StatusBar.tsx b/src/course-outline/status-bar/StatusBar.tsx index 93341d587d..d5f0918fd6 100644 --- a/src/course-outline/status-bar/StatusBar.tsx +++ b/src/course-outline/status-bar/StatusBar.tsx @@ -10,9 +10,9 @@ import { } from '@openedx/paragon/icons'; import { useWaffleFlags } from '@src/data/apiHooks'; import { useEntityLinksSummaryByDownstreamContext } from '@src/course-libraries/data/apiHooks'; +import { useCourseDetails } from '@src/course-outline/data/apiHooks'; import messages from './messages'; import { NotificationStatusIcon } from './NotificationStatusIcon'; -import { useCourseDetails } from '@src/course-outline/data/apiHooks'; const CourseBadge = ({ startDate, endDate }: { startDate: Moment, endDate: Moment }) => { const now = moment().utc(); @@ -58,7 +58,7 @@ const UnpublishedBadgeStatus = ({ courseId }: { courseId: string }) => { - ) + ); }; const LibraryUpdates = ({ courseId }: { courseId: string }) => { diff --git a/src/course-outline/subsection-card/SubsectionCard.tsx b/src/course-outline/subsection-card/SubsectionCard.tsx index f4bb6737fd..e5d0b642f6 100644 --- a/src/course-outline/subsection-card/SubsectionCard.tsx +++ b/src/course-outline/subsection-card/SubsectionCard.tsx @@ -27,9 +27,8 @@ import { invalidateLinksQuery } from '@src/course-libraries/data/apiHooks'; import { useCourseAuthoringContext } from '@src/CourseAuthoringContext'; import { useOutlineSidebarContext } from '@src/course-outline/outline-sidebar/OutlineSidebarContext'; import { courseOutlineQueryKeys, useCourseItemData } from '@src/course-outline/data/apiHooks'; -import messages from './messages'; -import { sub } from 'date-fns'; import moment from 'moment'; +import messages from './messages'; interface SubsectionCardProps { section: XBlock, diff --git a/src/course-outline/unit-card/UnitCard.tsx b/src/course-outline/unit-card/UnitCard.tsx index aeb01c8772..6098fe8f84 100644 --- a/src/course-outline/unit-card/UnitCard.tsx +++ b/src/course-outline/unit-card/UnitCard.tsx @@ -25,8 +25,8 @@ import { invalidateLinksQuery } from '@src/course-libraries/data/apiHooks'; import type { XBlock } from '@src/data/types'; import { useCourseAuthoringContext } from '@src/CourseAuthoringContext'; import { courseOutlineQueryKeys, useCourseItemData } from '@src/course-outline/data/apiHooks'; -import { useOutlineSidebarContext } from '../outline-sidebar/OutlineSidebarContext'; import moment from 'moment'; +import { useOutlineSidebarContext } from '../outline-sidebar/OutlineSidebarContext'; interface UnitCardProps { unit: XBlock; diff --git a/src/generic/resizable/Resizable.tsx b/src/generic/resizable/Resizable.tsx index b604380801..cf96080375 100644 --- a/src/generic/resizable/Resizable.tsx +++ b/src/generic/resizable/Resizable.tsx @@ -1,5 +1,7 @@ -import { useWindowSize } from "@openedx/paragon"; -import React, { useRef, useState, useCallback, useMemo } from "react"; +import { useWindowSize } from '@openedx/paragon'; +import React, { + useRef, useState, useCallback, useMemo, +} from 'react'; const MIN_WIDTH = 440; // px @@ -31,16 +33,6 @@ export const ResizableBox = ({ return Math.abs(windowWidth * 0.65); }, [windowWidth]); - const onMouseDown = useCallback((e: React.MouseEvent) => { - e.preventDefault(); // prevent text selection - startXRef.current = e.clientX; - startWidthRef.current = width; - - // Attach listeners to the whole document so dragging works even outside the box - document.addEventListener("mousemove", onMouseMove); - document.addEventListener("mouseup", onMouseUp); - }, [width]); - const onMouseMove = useCallback((e: MouseEvent) => { const dx = e.clientX - startXRef.current; // positive = mouse moved right const newWidth = Math.min( @@ -51,21 +43,31 @@ export const ResizableBox = ({ }, [maxWidth, minWidth, defaultMaxWidth]); const onMouseUp = useCallback(() => { - document.removeEventListener("mousemove", onMouseMove); - document.removeEventListener("mouseup", onMouseUp); + document.removeEventListener('mousemove', onMouseMove); + document.removeEventListener('mouseup', onMouseUp); }, [onMouseMove]); + const onMouseDown = useCallback((e: React.MouseEvent) => { + e.preventDefault(); // prevent text selection + startXRef.current = e.clientX; + startWidthRef.current = width; + + // Attach listeners to the whole document so dragging works even outside the box + document.addEventListener('mousemove', onMouseMove); + document.addEventListener('mouseup', onMouseUp); + }, [width]); + return ( -
-
-
- {children} -
+
+ {/* eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex, jsx-a11y/no-static-element-interactions */} +
+
+ {children}
+
); }; - diff --git a/src/generic/sidebar/SidebarTitle.tsx b/src/generic/sidebar/SidebarTitle.tsx index 00e9b61c73..c13abfd848 100644 --- a/src/generic/sidebar/SidebarTitle.tsx +++ b/src/generic/sidebar/SidebarTitle.tsx @@ -27,7 +27,7 @@ export const SidebarTitle = ({ {onBackBtnClick && ( diff --git a/src/library-authoring/library-filters/SidebarFilters.tsx b/src/library-authoring/library-filters/SidebarFilters.tsx index 1d6c0cc52b..1db7e2f1a9 100644 --- a/src/library-authoring/library-filters/SidebarFilters.tsx +++ b/src/library-authoring/library-filters/SidebarFilters.tsx @@ -19,7 +19,7 @@ export const SidebarFilters = ({ onlyOneType }: FiltersProps) => { return ( - + @@ -33,7 +33,7 @@ export const SidebarFilters = ({ onlyOneType }: FiltersProps) => { {isOn && ( - + {!(onlyOneType) && } From 1436c27552a99224b7d738e33f4e842bacbf1c0f Mon Sep 17 00:00:00 2001 From: Navin Karkera Date: Sun, 1 Feb 2026 16:32:25 +0530 Subject: [PATCH 30/60] refactor: delete --- src/course-outline/data/apiHooks.ts | 12 +- src/course-outline/data/thunk.ts | 49 -------- src/course-outline/hooks.jsx | 63 ++++++++-- .../outline-sidebar/AddSidebar.tsx | 117 +++++++++++------- .../outline-sidebar/OutlineSidebarContext.tsx | 2 +- 5 files changed, 135 insertions(+), 108 deletions(-) diff --git a/src/course-outline/data/apiHooks.ts b/src/course-outline/data/apiHooks.ts index 57d59fae58..b1c852f95f 100644 --- a/src/course-outline/data/apiHooks.ts +++ b/src/course-outline/data/apiHooks.ts @@ -5,7 +5,7 @@ import { skipToken, useMutation, useQuery, useQueryClient, } from '@tanstack/react-query'; import { - createCourseXblock, editItemDisplayName, getCourseDetails, getCourseItem, publishCourseItem, + createCourseXblock, deleteCourseItem, editItemDisplayName, getCourseDetails, getCourseItem, publishCourseItem, } from './api'; export const courseOutlineQueryKeys = { @@ -91,3 +91,13 @@ export const usePublishCourseItem = () => { }, }); }; + +export const useDeleteCourseItem = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: deleteCourseItem, + onSettled: async (_data, _err, itemId) => { + queryClient.invalidateQueries({ queryKey: courseOutlineQueryKeys.courseDetails(getCourseKey(itemId)) }); + }, + }); +} diff --git a/src/course-outline/data/thunk.ts b/src/course-outline/data/thunk.ts index 3be2d54270..b94c7cc106 100644 --- a/src/course-outline/data/thunk.ts +++ b/src/course-outline/data/thunk.ts @@ -354,55 +354,6 @@ export function configureCourseUnitQuery( }; } -/** - * Generic function to delete course item, see below wrapper funcs for specific implementations. - * @param {string} itemId - * @param {() => {}} deleteItemFn - */ -function deleteCourseItemQuery(itemId: string, deleteItemFn: () => {}) { - return async (dispatch) => { - dispatch(updateSavingStatus({ status: RequestStatus.PENDING })); - dispatch(showProcessingNotification(NOTIFICATION_MESSAGES.deleting)); - - try { - await deleteCourseItem(itemId); - dispatch(deleteItemFn()); - dispatch(hideProcessingNotification()); - dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL })); - } catch { - dispatch(hideProcessingNotification()); - dispatch(updateSavingStatus({ status: RequestStatus.FAILED })); - } - }; -} - -export function deleteCourseSectionQuery(sectionId: string) { - return async (dispatch) => { - dispatch(deleteCourseItemQuery( - sectionId, - () => deleteSection({ itemId: sectionId }), - )); - }; -} - -export function deleteCourseSubsectionQuery(subsectionId: string, sectionId: string) { - return async (dispatch) => { - dispatch(deleteCourseItemQuery( - subsectionId, - () => deleteSubsection({ itemId: subsectionId, sectionId }), - )); - }; -} - -export function deleteCourseUnitQuery(unitId: string, subsectionId: string, sectionId: string) { - return async (dispatch) => { - dispatch(deleteCourseItemQuery( - unitId, - () => deleteUnit({ itemId: unitId, subsectionId, sectionId }), - )); - }; -} - /** * Generic function to duplicate any course item. See wrapper functions below for specific implementations. * @param {string} itemId diff --git a/src/course-outline/hooks.jsx b/src/course-outline/hooks.jsx index 27704f9231..28f1685d4a 100644 --- a/src/course-outline/hooks.jsx +++ b/src/course-outline/hooks.jsx @@ -12,9 +12,12 @@ import { ContainerType, getBlockType } from '@src/generic/key-utils'; import { useOutlineSidebarContext } from '@src/course-outline/outline-sidebar/OutlineSidebarContext'; import { useUnlinkDownstream } from '@src/generic/unlink-modal'; import { useQueryClient } from '@tanstack/react-query'; -import { courseOutlineQueryKeys } from '@src/course-outline/data/apiHooks'; +import { courseOutlineQueryKeys, useDeleteCourseItem } from '@src/course-outline/data/apiHooks'; import { COURSE_BLOCK_NAMES } from './constants'; import { + deleteSection, + deleteSubsection, + deleteUnit, resetScrollField, updateSavingStatus, } from './data/slice'; @@ -30,9 +33,6 @@ import { getCreatedOn, } from './data/selectors'; import { - deleteCourseSectionQuery, - deleteCourseSubsectionQuery, - deleteCourseUnitQuery, duplicateSectionQuery, duplicateSubsectionQuery, duplicateUnitQuery, @@ -208,21 +208,60 @@ const useCourseOutline = ({ courseId }) => { handleConfigureModalClose(); }; - const handleDeleteItemSubmit = () => { + const deleteMutation = useDeleteCourseItem(); + + const handleDeleteItemSubmit = async () => { + // istanbul ignore if + if (!currentSelection) { + return; + } const category = getBlockType(currentSelection.currentId); switch (category) { case COURSE_BLOCK_NAMES.chapter.id: - dispatch(deleteCourseSectionQuery(currentSelection?.currentId)); + await deleteMutation.mutateAsync( + currentSelection.currentId, + { + onSettled: () => dispatch(deleteSection({ itemId: sectionId })), + } + ); break; case COURSE_BLOCK_NAMES.sequential.id: - dispatch(deleteCourseSubsectionQuery(currentSelection?.currentId, currentSelection?.sectionId)); + await deleteMutation.mutateAsync( + currentSelection.currentId, + { + onSettled: () => { + dispatch(deleteSubsection({ + itemId: currentSelection.currentId, + sectionId: currentSelection.sectionId, + })); + // invalidate parent section data + queryClient.invalidateQueries({ + queryKey: courseOutlineQueryKeys.courseItemId(currentSelection.sectionId), + }); + } + } + ); break; case COURSE_BLOCK_NAMES.vertical.id: - dispatch(deleteCourseUnitQuery( - currentSelection?.currentId, - currentSelection?.subsectionId, - currentSelection?.sectionId, - )); + await deleteMutation.mutateAsync( + currentSelection.currentId, + { + onSettled: () => { + dispatch(deleteUnit({ + itemId: currentSelection.currentId, + subsectionId: currentSelection.subsectionId, + sectionId: currentSelection.sectionId, + })); + // invalidate parent subsection and section data + queryClient.invalidateQueries({ + queryKey: courseOutlineQueryKeys.courseItemId(currentSelection.subsectionId), + }); + queryClient.invalidateQueries({ + queryKey: courseOutlineQueryKeys.courseItemId(currentSelection.sectionId), + }); + } + } + ); break; default: // istanbul ignore next diff --git a/src/course-outline/outline-sidebar/AddSidebar.tsx b/src/course-outline/outline-sidebar/AddSidebar.tsx index aea32caceb..23fcdba82d 100644 --- a/src/course-outline/outline-sidebar/AddSidebar.tsx +++ b/src/course-outline/outline-sidebar/AddSidebar.tsx @@ -59,42 +59,71 @@ const AddContentButton = ({ name, blockType } : AddContentButtonProps) => { stopCurrentFlow, lastEditableSection, lastEditableSubsection, - currentItemData, openContainerInfoSidebar, } = useOutlineSidebarContext(); const queryClient = useQueryClient(); let sectionParentId = lastEditableSection?.id; let subsectionParentId = lastEditableSubsection?.data?.id; + const addSection = async (onSuccess?: (data: { locator: string; }) => void) => { + await handleAddSection.mutateAsync({ + type: ContainerType.Chapter, + parentLocator: courseUsageKey, + displayName: COURSE_BLOCK_NAMES.chapter.name, + }, { + onSuccess: (data: { locator: string; }) => { + if (onSuccess) { + onSuccess(data); + } else { + openContainerInfoSidebar(data.locator, undefined, data.locator) + } + }, + }); + } + + const addSubsection = async (sectionParentId: string, onSuccess?: (data: { locator: string; }) => void) => { + await handleAddSubsection.mutateAsync({ + type: ContainerType.Sequential, + parentLocator: sectionParentId, + displayName: COURSE_BLOCK_NAMES.sequential.name, + }, { + onSuccess: (data: { locator: string; }) => { + if (onSuccess) { + onSuccess(data); + } else { + openContainerInfoSidebar(data.locator, data.locator, sectionParentId) + } + }, + }); + } + + const addUnit = async (subsectionParentId: string, sectionParentId?: string, onSettled?: () => void) => { + await handleAddAndOpenUnit.mutateAsync({ + type: ContainerType.Vertical, + parentLocator: subsectionParentId, + displayName: COURSE_BLOCK_NAMES.vertical.name, + }, { + onSettled: () => { + if (onSettled) { + onSettled(); + } else { + queryClient.invalidateQueries({ queryKey: courseOutlineQueryKeys.courseItemId(sectionParentId) }) + } + }, + }); + } + const onCreateContent = useCallback(async () => { switch (blockType) { case 'section': - await handleAddSection.mutateAsync({ - type: ContainerType.Chapter, - parentLocator: courseUsageKey, - displayName: COURSE_BLOCK_NAMES.chapter.name, - }, { - onSuccess: (data: { locator: string; }) => openContainerInfoSidebar( - data.locator, - undefined, - data.locator, - ), - }); + addSection(); break; case 'subsection': sectionParentId = currentFlow?.parentLocator || sectionParentId; if (sectionParentId) { - await handleAddSubsection.mutateAsync({ - type: ContainerType.Sequential, - parentLocator: sectionParentId, - displayName: COURSE_BLOCK_NAMES.sequential.name, - }, { - onSuccess: (data: { locator: string; }) => openContainerInfoSidebar( - data.locator, - data.locator, - sectionParentId, - ), - }); + addSubsection(sectionParentId); + } else { + addSection(({ locator }) => addSubsection(locator)); } break; case 'unit': @@ -102,17 +131,17 @@ const AddContentButton = ({ name, blockType } : AddContentButtonProps) => { currentFlow?.grandParentLocator || lastEditableSubsection?.sectionId || sectionParentId ); subsectionParentId = currentFlow?.parentLocator || subsectionParentId; + // __AUTO_GENERATED_PRINT_VAR_START__ + console.log("AddContentButton#(anon) subsectionParentId: ", subsectionParentId); // __AUTO_GENERATED_PRINT_VAR_END__ if (subsectionParentId) { - await handleAddAndOpenUnit.mutateAsync({ - type: ContainerType.Vertical, - parentLocator: subsectionParentId, - displayName: COURSE_BLOCK_NAMES.vertical.name, - }, { - onSettled: () => { - queryClient.invalidateQueries({ - queryKey: courseOutlineQueryKeys.courseItemId(sectionParentId), - }); - }, + addUnit(subsectionParentId, sectionParentId); + } else if (sectionParentId) { + addSubsection(sectionParentId, ({ locator }) => addUnit(locator)); + } else { + addSection(({ locator: sectionId }) => { + addSubsection(sectionId, ({ locator: subsectionId }) => { + addUnit(subsectionId, sectionId) + }) }); } break; @@ -129,21 +158,19 @@ const AddContentButton = ({ name, blockType } : AddContentButtonProps) => { handleAddSubsection, handleAddAndOpenUnit, currentFlow, - lastEditableSection, + sectionParentId, + subsectionParentId, lastEditableSubsection, ]); - const enabled = useMemo(() => ( - (currentFlow) - || (blockType === 'subsection' && lastEditableSection) - || (blockType === 'unit' && lastEditableSubsection) - || (blockType === 'section' && !currentItemData) + const disabled = useMemo(() => ( + handleAddSection.isPending || + handleAddSubsection.isPending || + handleAddAndOpenUnit.isPending ), [ - currentFlow, - blockType, - currentItemData, - lastEditableSection, - lastEditableSubsection, + handleAddSection, + handleAddSubsection, + handleAddAndOpenUnit, ]); return ( @@ -151,7 +178,7 @@ const AddContentButton = ({ name, blockType } : AddContentButtonProps) => { variant="tertiary shadow" className="mx-2 justify-content-start px-4 font-weight-bold" onClick={onCreateContent} - disabled={!enabled} + disabled={disabled} > diff --git a/src/course-outline/outline-sidebar/OutlineSidebarContext.tsx b/src/course-outline/outline-sidebar/OutlineSidebarContext.tsx index 8af1c50335..c094eddd21 100644 --- a/src/course-outline/outline-sidebar/OutlineSidebarContext.tsx +++ b/src/course-outline/outline-sidebar/OutlineSidebarContext.tsx @@ -169,7 +169,7 @@ export const OutlineSidebarProvider = ({ children }: { children?: React.ReactNod }; } return currentItemData ? undefined : getLastEditableSubsection(sectionsList); - }, [currentItemData, sectionsList]); + }, [currentItemData, sectionsList, selectedContainerState]); useEscapeClick({ onEscape: () => { From 4d34deb587d8a72fae3884abea1e48a77d5681d8 Mon Sep 17 00:00:00 2001 From: Navin Karkera Date: Sun, 1 Feb 2026 16:40:17 +0530 Subject: [PATCH 31/60] fix: rebase issues and delete typo --- src/course-outline/hooks.jsx | 17 ++++++++++++++--- .../OutlineSidebarPagesContext.tsx | 4 ++-- 2 files changed, 16 insertions(+), 5 deletions(-) diff --git a/src/course-outline/hooks.jsx b/src/course-outline/hooks.jsx index 28f1685d4a..39afbe153c 100644 --- a/src/course-outline/hooks.jsx +++ b/src/course-outline/hooks.jsx @@ -210,7 +210,7 @@ const useCourseOutline = ({ courseId }) => { const deleteMutation = useDeleteCourseItem(); - const handleDeleteItemSubmit = async () => { + const handleDeleteItemSubmit = useCallback(async () => { // istanbul ignore if if (!currentSelection) { return; @@ -221,7 +221,7 @@ const useCourseOutline = ({ courseId }) => { await deleteMutation.mutateAsync( currentSelection.currentId, { - onSettled: () => dispatch(deleteSection({ itemId: sectionId })), + onSettled: () => dispatch(deleteSection({ itemId: currentSelection.currentId })), } ); break; @@ -271,7 +271,18 @@ const useCourseOutline = ({ courseId }) => { if (selectedContainerState.currentId === currentSelection?.currentId) { clearSelection(); } - }; + }, [ + deleteMutation, + clearSelection, + closeDeleteModal, + queryClient, + currentSelection, + courseOutlineQueryKeys, + dispatch, + deleteSection, + deleteUnit, + deleteSubsection, + ]); const handleDuplicateSectionSubmit = () => { dispatch(duplicateSectionQuery(currentSelection?.sectionId, courseStructure.id)); diff --git a/src/course-outline/outline-sidebar/OutlineSidebarPagesContext.tsx b/src/course-outline/outline-sidebar/OutlineSidebarPagesContext.tsx index 8aaf0412ab..e3fe73e578 100644 --- a/src/course-outline/outline-sidebar/OutlineSidebarPagesContext.tsx +++ b/src/course-outline/outline-sidebar/OutlineSidebarPagesContext.tsx @@ -9,7 +9,7 @@ import type { SidebarPage } from '@src/generic/sidebar'; import { AddSidebar } from './AddSidebar'; import { OutlineAlignSidebar } from './OutlineAlignSidebar'; import OutlineHelpSidebar from './OutlineHelpSidebar'; -import { OutlineInfoSidebar } from './OutlineInfoSidebar'; +import { InfoSidebar } from './InfoSidebar'; import messages from './messages'; export type OutlineSidebarPages = { @@ -21,7 +21,7 @@ export type OutlineSidebarPages = { const getOutlineSidebarPages = () => ({ info: { - component: OutlineInfoSidebar, + component: InfoSidebar, icon: Info, title: messages.sidebarButtonInfo, }, From a56961f32968fbe4d1203df2bcf1461964216dc2 Mon Sep 17 00:00:00 2001 From: Navin Karkera Date: Sun, 1 Feb 2026 16:44:24 +0530 Subject: [PATCH 32/60] fix: lint issues --- src/course-outline/data/apiHooks.ts | 2 +- src/course-outline/data/thunk.ts | 4 - src/course-outline/hooks.jsx | 10 +-- .../outline-sidebar/AddSidebar.tsx | 80 +++++++++---------- 4 files changed, 45 insertions(+), 51 deletions(-) diff --git a/src/course-outline/data/apiHooks.ts b/src/course-outline/data/apiHooks.ts index b1c852f95f..a2cf7b37c6 100644 --- a/src/course-outline/data/apiHooks.ts +++ b/src/course-outline/data/apiHooks.ts @@ -100,4 +100,4 @@ export const useDeleteCourseItem = () => { queryClient.invalidateQueries({ queryKey: courseOutlineQueryKeys.courseDetails(getCourseKey(itemId)) }); }, }); -} +}; diff --git a/src/course-outline/data/thunk.ts b/src/course-outline/data/thunk.ts index b94c7cc106..8441715cfa 100644 --- a/src/course-outline/data/thunk.ts +++ b/src/course-outline/data/thunk.ts @@ -11,7 +11,6 @@ import { } from '../utils/getChecklistForStatusBar'; import { getErrorDetails } from '../utils/getErrorDetails'; import { - deleteCourseItem, duplicateCourseItem, enableCourseHighlightsEmails, getCourseBestPractices, @@ -40,9 +39,6 @@ import { updateSavingStatus, updateSectionList, updateFetchSectionLoadingStatus, - deleteSection, - deleteSubsection, - deleteUnit, duplicateSection, reorderSectionList, setPasteFileNotices, diff --git a/src/course-outline/hooks.jsx b/src/course-outline/hooks.jsx index 39afbe153c..7ce72260cb 100644 --- a/src/course-outline/hooks.jsx +++ b/src/course-outline/hooks.jsx @@ -222,7 +222,7 @@ const useCourseOutline = ({ courseId }) => { currentSelection.currentId, { onSettled: () => dispatch(deleteSection({ itemId: currentSelection.currentId })), - } + }, ); break; case COURSE_BLOCK_NAMES.sequential.id: @@ -238,8 +238,8 @@ const useCourseOutline = ({ courseId }) => { queryClient.invalidateQueries({ queryKey: courseOutlineQueryKeys.courseItemId(currentSelection.sectionId), }); - } - } + }, + }, ); break; case COURSE_BLOCK_NAMES.vertical.id: @@ -259,8 +259,8 @@ const useCourseOutline = ({ courseId }) => { queryClient.invalidateQueries({ queryKey: courseOutlineQueryKeys.courseItemId(currentSelection.sectionId), }); - } - } + }, + }, ); break; default: diff --git a/src/course-outline/outline-sidebar/AddSidebar.tsx b/src/course-outline/outline-sidebar/AddSidebar.tsx index 23fcdba82d..f0b5cad846 100644 --- a/src/course-outline/outline-sidebar/AddSidebar.tsx +++ b/src/course-outline/outline-sidebar/AddSidebar.tsx @@ -71,47 +71,47 @@ const AddContentButton = ({ name, blockType } : AddContentButtonProps) => { parentLocator: courseUsageKey, displayName: COURSE_BLOCK_NAMES.chapter.name, }, { - onSuccess: (data: { locator: string; }) => { - if (onSuccess) { - onSuccess(data); - } else { - openContainerInfoSidebar(data.locator, undefined, data.locator) - } - }, - }); - } + onSuccess: (data: { locator: string; }) => { + if (onSuccess) { + onSuccess(data); + } else { + openContainerInfoSidebar(data.locator, undefined, data.locator); + } + }, + }); + }; - const addSubsection = async (sectionParentId: string, onSuccess?: (data: { locator: string; }) => void) => { + const addSubsection = async (sectionId: string, onSuccess?: (data: { locator: string; }) => void) => { await handleAddSubsection.mutateAsync({ type: ContainerType.Sequential, - parentLocator: sectionParentId, + parentLocator: sectionId, displayName: COURSE_BLOCK_NAMES.sequential.name, }, { - onSuccess: (data: { locator: string; }) => { - if (onSuccess) { - onSuccess(data); - } else { - openContainerInfoSidebar(data.locator, data.locator, sectionParentId) - } - }, - }); - } + onSuccess: (data: { locator: string; }) => { + if (onSuccess) { + onSuccess(data); + } else { + openContainerInfoSidebar(data.locator, data.locator, sectionId); + } + }, + }); + }; - const addUnit = async (subsectionParentId: string, sectionParentId?: string, onSettled?: () => void) => { + const addUnit = async (subsectionId: string, sectionId?: string, onSettled?: () => void) => { await handleAddAndOpenUnit.mutateAsync({ type: ContainerType.Vertical, - parentLocator: subsectionParentId, + parentLocator: subsectionId, displayName: COURSE_BLOCK_NAMES.vertical.name, }, { - onSettled: () => { - if (onSettled) { - onSettled(); - } else { - queryClient.invalidateQueries({ queryKey: courseOutlineQueryKeys.courseItemId(sectionParentId) }) - } - }, - }); - } + onSettled: () => { + if (onSettled) { + onSettled(); + } else { + queryClient.invalidateQueries({ queryKey: courseOutlineQueryKeys.courseItemId(sectionId) }); + } + }, + }); + }; const onCreateContent = useCallback(async () => { switch (blockType) { @@ -131,8 +131,6 @@ const AddContentButton = ({ name, blockType } : AddContentButtonProps) => { currentFlow?.grandParentLocator || lastEditableSubsection?.sectionId || sectionParentId ); subsectionParentId = currentFlow?.parentLocator || subsectionParentId; - // __AUTO_GENERATED_PRINT_VAR_START__ - console.log("AddContentButton#(anon) subsectionParentId: ", subsectionParentId); // __AUTO_GENERATED_PRINT_VAR_END__ if (subsectionParentId) { addUnit(subsectionParentId, sectionParentId); } else if (sectionParentId) { @@ -140,8 +138,8 @@ const AddContentButton = ({ name, blockType } : AddContentButtonProps) => { } else { addSection(({ locator: sectionId }) => { addSubsection(sectionId, ({ locator: subsectionId }) => { - addUnit(subsectionId, sectionId) - }) + addUnit(subsectionId, sectionId); + }); }); } break; @@ -164,13 +162,13 @@ const AddContentButton = ({ name, blockType } : AddContentButtonProps) => { ]); const disabled = useMemo(() => ( - handleAddSection.isPending || - handleAddSubsection.isPending || - handleAddAndOpenUnit.isPending + handleAddSection.isPending + || handleAddSubsection.isPending + || handleAddAndOpenUnit.isPending ), [ - handleAddSection, - handleAddSubsection, - handleAddAndOpenUnit, + handleAddSection, + handleAddSubsection, + handleAddAndOpenUnit, ]); return ( From 4976d0f0b9d93fa51a9da5e8abde61f15e983170 Mon Sep 17 00:00:00 2001 From: Navin Karkera Date: Mon, 2 Feb 2026 11:22:27 +0530 Subject: [PATCH 33/60] fix: failing tests --- src/course-outline/CourseOutline.test.tsx | 170 +++++++++--------- .../OutlineAddChildButtons.test.tsx | 3 + .../outline-sidebar/AddSidebar.test.tsx | 14 +- .../outline-sidebar/OutlineSidebar.test.tsx | 1 + .../section-card/SectionCard.test.tsx | 42 +++-- .../status-bar/StatusBar.test.tsx | 2 +- 6 files changed, 126 insertions(+), 106 deletions(-) diff --git a/src/course-outline/CourseOutline.test.tsx b/src/course-outline/CourseOutline.test.tsx index 6d820f5d9a..85f1277010 100644 --- a/src/course-outline/CourseOutline.test.tsx +++ b/src/course-outline/CourseOutline.test.tsx @@ -447,7 +447,7 @@ describe('', () => { axiosMock .onPost(getXBlockBaseApiUrl()) .reply(200, { - locator: 'some', + locator: 'block-v1:UNIX+UX1+2025_T3+type@vertical+block@vertical1e842129', }); const newUnitButton = await within(subsectionElement).findByRole('button', { name: 'New unit' }); await act(async () => fireEvent.click(newUnitButton)); @@ -474,7 +474,7 @@ describe('', () => { axiosMock .onPost(postXBlockBaseApiUrl()) .reply(200, { - locator: 'some', + locator: 'block-v1:UNIX+UX1+2025_T3+type@vertical+block@vertical1e842129', parent_locator: 'parent', }); @@ -512,8 +512,8 @@ describe('', () => { axiosMock .onPost(postXBlockBaseApiUrl()) .reply(200, { - locator: 'some', - parent_locator: 'parent', + locator: 'block-v1:UNIX+UX1+2025_T3+type@sequential+block@sequential45d4d95a', + parent_locator: 'block-v1:UNIX+UX1+2025_T3+type@chapter+block@chaptersda1', }); const addSubsectionFromLibraryButton = within(sectionElement).getByRole('button', { @@ -548,8 +548,8 @@ describe('', () => { axiosMock .onPost(postXBlockBaseApiUrl()) .reply(200, { - locator: 'some', - parent_locator: 'parent', + locator: 'block-v1:UNIX+UX1+2025_T3+type@chapter+block@chaptersdafdd', + courseKey: 'course-v1:UNIX+UX1+2025_T3', }); const addSectionFromLibraryButton = await screen.findByRole('button', { @@ -958,6 +958,7 @@ describe('', () => { }); it('check configure modal for subsection', async () => { + const user = userEvent.setup(); const { findAllByTestId, findByTestId, @@ -999,14 +1000,14 @@ describe('', () => { subsection.isTimeLimited = expectedRequestData.metadata.is_time_limited; subsection.defaultTimeLimitMinutes = expectedRequestData.metadata.default_time_limit_minutes; subsection.hideAfterDue = expectedRequestData.metadata.hide_after_due; - section.childInfo.children[0] = subsection; axiosMock - .onGet(getXBlockApiUrl(section.id)) - .reply(200, section); + .onGet(getXBlockApiUrl(subsection.id)) + .reply(200, subsection); + section.childInfo.children[0] = subsection; - fireEvent.click(subsectionDropdownButton); + await user.click(subsectionDropdownButton); const configureBtn = await within(firstSubsection).findByTestId('subsection-card-header__menu-configure-button'); - fireEvent.click(configureBtn); + await user.click(configureBtn); // update fields let configureModal = await findByTestId('configure-modal'); @@ -1026,27 +1027,27 @@ describe('', () => { // visibility tab const visibilityTab = await within(configureModal).findByRole('tab', { name: configureModalMessages.visibilityTabTitle.defaultMessage }); - fireEvent.click(visibilityTab); + await user.click(visibilityTab); const visibilityRadioButtons = await within(configureModal).findAllByRole('radio'); - fireEvent.click(visibilityRadioButtons[1]); + await user.click(visibilityRadioButtons[1]); let advancedTab = await within(configureModal).findByRole('tab', { name: configureModalMessages.advancedTabTitle.defaultMessage }); - fireEvent.click(advancedTab); + await user.click(advancedTab); let radioButtons = await within(configureModal).findAllByRole('radio'); - fireEvent.click(radioButtons[1]); + await user.click(radioButtons[1]); let hoursWrapper = await within(configureModal).findByTestId('advanced-tab-hours-picker-wrapper'); let hours = await within(hoursWrapper).findByPlaceholderText('HH:MM'); fireEvent.change(hours, { target: { value: '54:30' } }); const saveButton = await within(configureModal).findByTestId('configure-save-button'); - await act(async () => fireEvent.click(saveButton)); + await user.click(saveButton); // verify request expect(axiosMock.history.post.length).toBe(3); expect(axiosMock.history.post[2].data).toBe(JSON.stringify(expectedRequestData)); // reopen modal and check values - await act(async () => fireEvent.click(subsectionDropdownButton)); - await act(async () => fireEvent.click(configureBtn)); + await user.click(subsectionDropdownButton); + await user.click(configureBtn); configureModal = await findByTestId('configure-modal'); releaseDateStack = await within(configureModal).findByTestId('release-date-stack'); @@ -1063,7 +1064,7 @@ describe('', () => { expect(graderTypeDropdown).toHaveValue(expectedRequestData.graderType); advancedTab = await within(configureModal).findByRole('tab', { name: configureModalMessages.advancedTabTitle.defaultMessage }); - fireEvent.click(advancedTab); + await user.click(advancedTab); radioButtons = await within(configureModal).findAllByRole('radio'); expect(radioButtons[0]).toHaveProperty('checked', false); expect(radioButtons[1]).toHaveProperty('checked', true); @@ -1073,6 +1074,7 @@ describe('', () => { }); it('check prereq and proctoring settings in configure modal for subsection', async () => { + const user = userEvent.setup(); const { findAllByTestId, findByTestId, @@ -1120,13 +1122,10 @@ describe('', () => { subsection.prereqMinScore = expectedRequestData.prereqMinScore; subsection.prereqMinCompletion = expectedRequestData.prereqMinCompletion; section.childInfo.children[0] = subsection; - axiosMock - .onGet(getXBlockApiUrl(section.id)) - .reply(200, section); - fireEvent.click(subsectionDropdownButton); + await user.click(subsectionDropdownButton); const configureBtn = await within(firstSubsection).findByTestId('subsection-card-header__menu-configure-button'); - fireEvent.click(configureBtn); + await user.click(configureBtn); // update fields let configureModal = await findByTestId('configure-modal'); @@ -1137,13 +1136,13 @@ describe('', () => { // visibility tab const visibilityTab = await within(configureModal).findByRole('tab', { name: configureModalMessages.visibilityTabTitle.defaultMessage }); - fireEvent.click(visibilityTab); + await user.click(visibilityTab); const visibilityRadioButtons = await within(configureModal).findAllByRole('radio'); - fireEvent.click(visibilityRadioButtons[2]); + await user.click(visibilityRadioButtons[2]); - fireEvent.click(advancedTab); + await user.click(advancedTab); let radioButtons = await within(configureModal).findAllByRole('radio'); - fireEvent.click(radioButtons[2]); + await user.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' } }); @@ -1165,7 +1164,7 @@ describe('', () => { let prereqCheckbox = await within(configureModal).findByLabelText( configureModalMessages.prereqCheckboxLabel.defaultMessage, ); - fireEvent.click(prereqCheckbox); + await user.click(prereqCheckbox); // fill some rules for proctored exams let examsRulesInput = await within(configureModal).findByLabelText( @@ -1173,22 +1172,25 @@ describe('', () => { ); fireEvent.change(examsRulesInput, { target: { value: expectedRequestData.metadata.exam_review_rules } }); + axiosMock + .onGet(getXBlockApiUrl(subsection.id)) + .reply(200, subsection); const saveButton = await within(configureModal).findByTestId('configure-save-button'); - await act(async () => fireEvent.click(saveButton)); + await user.click(saveButton); // verify request expect(axiosMock.history.post.length).toBe(3); expect(axiosMock.history.post[2].data).toBe(JSON.stringify(expectedRequestData)); // reopen modal and check values - await act(async () => fireEvent.click(subsectionDropdownButton)); - await act(async () => fireEvent.click(configureBtn)); + await user.click(subsectionDropdownButton); + await user.click(configureBtn); configureModal = await findByTestId('configure-modal'); advancedTab = await within(configureModal).findByRole('tab', { name: configureModalMessages.advancedTabTitle.defaultMessage, }); - fireEvent.click(advancedTab); + await user.click(advancedTab); radioButtons = await within(configureModal).findAllByRole('radio'); expect(radioButtons[0]).toHaveProperty('checked', false); expect(radioButtons[1]).toHaveProperty('checked', false); @@ -1218,6 +1220,7 @@ describe('', () => { }); it('check practice proctoring settings in configure modal', async () => { + const user = userEvent.setup(); const { findAllByTestId, findByTestId, @@ -1260,13 +1263,10 @@ describe('', () => { 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); + await user.click(subsectionDropdownButton); const configureBtn = await within(firstSubsection).findByTestId('subsection-card-header__menu-configure-button'); - fireEvent.click(configureBtn); + await user.click(configureBtn); // update fields let configureModal = await findByTestId('configure-modal'); @@ -1276,14 +1276,14 @@ describe('', () => { ); // visibility tab const visibilityTab = await within(configureModal).findByRole('tab', { name: configureModalMessages.visibilityTabTitle.defaultMessage }); - fireEvent.click(visibilityTab); + await user.click(visibilityTab); const visibilityRadioButtons = await within(configureModal).findAllByRole('radio'); - fireEvent.click(visibilityRadioButtons[4]); + await user.click(visibilityRadioButtons[4]); // advancedTab - fireEvent.click(advancedTab); + await user.click(advancedTab); let radioButtons = await within(configureModal).findAllByRole('radio'); - fireEvent.click(radioButtons[3]); + await user.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' } }); @@ -1293,20 +1293,23 @@ describe('', () => { configureModalMessages.reviewRulesLabel.defaultMessage, )).not.toBeInTheDocument(); + axiosMock + .onGet(getXBlockApiUrl(subsection.id)) + .reply(200, subsection); const saveButton = await within(configureModal).findByTestId('configure-save-button'); - await act(async () => fireEvent.click(saveButton)); + await user.click(saveButton); // verify request expect(axiosMock.history.post.length).toBe(3); expect(axiosMock.history.post[2].data).toBe(JSON.stringify(expectedRequestData)); // reopen modal and check values - await act(async () => fireEvent.click(subsectionDropdownButton)); - await act(async () => fireEvent.click(configureBtn)); + await user.click(subsectionDropdownButton); + await user.click(configureBtn); configureModal = await findByTestId('configure-modal'); advancedTab = await within(configureModal).findByRole('tab', { name: configureModalMessages.advancedTabTitle.defaultMessage }); - fireEvent.click(advancedTab); + await user.click(advancedTab); radioButtons = await within(configureModal).findAllByRole('radio'); expect(radioButtons[0]).toHaveProperty('checked', false); expect(radioButtons[1]).toHaveProperty('checked', false); @@ -1318,6 +1321,7 @@ describe('', () => { }); it('check onboarding proctoring settings in configure modal', async () => { + const user = userEvent.setup(); const { findAllByTestId, findByTestId, @@ -1360,30 +1364,27 @@ describe('', () => { 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); + await user.click(subsectionDropdownButton); const configureBtn = await within(secondSubsection).findByTestId('subsection-card-header__menu-configure-button'); - fireEvent.click(configureBtn); + await user.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); + await user.click(visibilityTab); const visibilityRadioButtons = await within(configureModal).findAllByRole('radio'); - fireEvent.click(visibilityRadioButtons[5]); + await user.click(visibilityRadioButtons[5]); // advancedTab let advancedTab = await within(configureModal).findByRole( 'tab', { name: configureModalMessages.advancedTabTitle.defaultMessage }, ); - fireEvent.click(advancedTab); + await user.click(advancedTab); let radioButtons = await within(configureModal).findAllByRole('radio'); - fireEvent.click(radioButtons[3]); + await user.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' } }); @@ -1393,20 +1394,23 @@ describe('', () => { configureModalMessages.reviewRulesLabel.defaultMessage, )).not.toBeInTheDocument(); + axiosMock + .onGet(getXBlockApiUrl(subsection.id)) + .reply(200, subsection); const saveButton = await within(configureModal).findByTestId('configure-save-button'); - await act(async () => fireEvent.click(saveButton)); + await user.click(saveButton); // verify request expect(axiosMock.history.post.length).toBe(3); expect(axiosMock.history.post[2].data).toBe(JSON.stringify(expectedRequestData)); // reopen modal and check values - await act(async () => fireEvent.click(subsectionDropdownButton)); - await act(async () => fireEvent.click(configureBtn)); + await user.click(subsectionDropdownButton); + await user.click(configureBtn); configureModal = await findByTestId('configure-modal'); advancedTab = await within(configureModal).findByRole('tab', { name: configureModalMessages.advancedTabTitle.defaultMessage }); - fireEvent.click(advancedTab); + await user.click(advancedTab); radioButtons = await within(configureModal).findAllByRole('radio'); expect(radioButtons[0]).toHaveProperty('checked', false); expect(radioButtons[1]).toHaveProperty('checked', false); @@ -1418,6 +1422,7 @@ describe('', () => { }); it('check no special exam setting in configure modal', async () => { + const user = userEvent.setup(); const { findAllByTestId, findByTestId, @@ -1459,13 +1464,10 @@ describe('', () => { 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); + await user.click(subsectionDropdownButton); const configureBtn = await within(subsectionElement).findByTestId('subsection-card-header__menu-configure-button'); - fireEvent.click(configureBtn); + await user.click(configureBtn); // update fields let configureModal = await findByTestId('configure-modal'); @@ -1475,9 +1477,9 @@ describe('', () => { 'tab', { name: configureModalMessages.advancedTabTitle.defaultMessage }, ); - fireEvent.click(advancedTab); + await user.click(advancedTab); let radioButtons = await within(configureModal).findAllByRole('radio'); - fireEvent.click(radioButtons[0]); + await user.click(radioButtons[0]); // time box should not be visible expect(within(configureModal).queryByLabelText( @@ -1489,20 +1491,23 @@ describe('', () => { configureModalMessages.reviewRulesLabel.defaultMessage, )).not.toBeInTheDocument(); + axiosMock + .onGet(getXBlockApiUrl(subsection.id)) + .reply(200, subsection); const saveButton = await within(configureModal).findByTestId('configure-save-button'); - await act(async () => fireEvent.click(saveButton)); + await user.click(saveButton); // verify request expect(axiosMock.history.post.length).toBe(3); expect(axiosMock.history.post[2].data).toBe(JSON.stringify(expectedRequestData)); // reopen modal and check values - await act(async () => fireEvent.click(subsectionDropdownButton)); - await act(async () => fireEvent.click(configureBtn)); + await user.click(subsectionDropdownButton); + await user.click(configureBtn); configureModal = await findByTestId('configure-modal'); advancedTab = await within(configureModal).findByRole('tab', { name: configureModalMessages.advancedTabTitle.defaultMessage }); - fireEvent.click(advancedTab); + await user.click(advancedTab); radioButtons = await within(configureModal).findAllByRole('radio'); expect(radioButtons[0]).toHaveProperty('checked', true); expect(radioButtons[1]).toHaveProperty('checked', false); @@ -1511,6 +1516,7 @@ describe('', () => { }); it('check configure modal for unit', async () => { + const user = userEvent.setup(); const { findAllByTestId, findByTestId } = renderComponent(); const section = courseOutlineIndexMock.courseStructure.childInfo.children[0]; const [subsection] = section.childInfo.children; @@ -1570,37 +1576,37 @@ describe('', () => { subsection.childInfo.children[0] = unit; section.childInfo.children[0] = subsection; - axiosMock - .onGet(getXBlockApiUrl(section.id)) - .reply(200, section); - - fireEvent.click(unitDropdownButton); + await user.click(unitDropdownButton); const configureBtn = await within(firstUnit).findByTestId('unit-card-header__menu-configure-button'); - fireEvent.click(configureBtn); + await user.click(configureBtn); let configureModal = await findByTestId('configure-modal'); expect(await within(configureModal).findByText( configureModalMessages.unitVisibility.defaultMessage, )).toBeInTheDocument(); let visibilityCheckbox = await within(configureModal).findByTestId('unit-visibility-checkbox'); - await act(async () => fireEvent.click(visibilityCheckbox)); + await user.click(visibilityCheckbox); let discussionCheckbox = await within(configureModal).findByLabelText( configureModalMessages.discussionEnabledCheckbox.defaultMessage, ); expect(discussionCheckbox).toBeChecked(); - await act(async () => fireEvent.click(discussionCheckbox)); + await user.click(discussionCheckbox); let groupeType = await within(configureModal).findByTestId('group-type-select'); fireEvent.change(groupeType, { target: { value: '0' } }); let checkboxes = await within(await within(configureModal).findByTestId('group-checkboxes')).findAllByRole('checkbox'); - fireEvent.click(checkboxes[1]); + await user.click(checkboxes[1]); + axiosMock + .onGet(getXBlockApiUrl(unit.id)) + .reply(200, unit); + const saveButton = await within(configureModal).findByTestId('configure-save-button'); - await act(async () => fireEvent.click(saveButton)); + await user.click(saveButton); // reopen modal and check values - await act(async () => fireEvent.click(unitDropdownButton)); - await act(async () => fireEvent.click(configureBtn)); + await user.click(unitDropdownButton); + await user.click(configureBtn); configureModal = await findByTestId('configure-modal'); visibilityCheckbox = await within(configureModal).findByTestId('unit-visibility-checkbox'); diff --git a/src/course-outline/OutlineAddChildButtons.test.tsx b/src/course-outline/OutlineAddChildButtons.test.tsx index ec004b7a81..02c658b8b6 100644 --- a/src/course-outline/OutlineAddChildButtons.test.tsx +++ b/src/course-outline/OutlineAddChildButtons.test.tsx @@ -15,6 +15,7 @@ jest.mock('react-redux', () => ({ const handleAddSection = { mutateAsync: jest.fn() }; const handleAddSubsection = { mutateAsync: jest.fn() }; const handleAddAndOpenUnit = { mutateAsync: jest.fn() }; +const handleAddUnit = { mutateAsync: jest.fn() }; const courseUsageKey = 'some/usage/key'; const setCurrentSelection = jest.fn(); jest.mock('@src/CourseAuthoringContext', () => ({ @@ -25,6 +26,7 @@ jest.mock('@src/CourseAuthoringContext', () => ({ handleAddSection, handleAddSubsection, handleAddAndOpenUnit, + handleAddUnit, setCurrentSelection, }), })); @@ -37,6 +39,7 @@ jest.mock('@src/course-outline/outline-sidebar/OutlineSidebarContext', () => ({ ...jest.requireActual('@src/course-outline/outline-sidebar/OutlineSidebarContext').useOutlineSidebarContext(), startCurrentFlow, currentFlow, + isCurrentFlowOn: !!currentFlow, }), })); diff --git a/src/course-outline/outline-sidebar/AddSidebar.test.tsx b/src/course-outline/outline-sidebar/AddSidebar.test.tsx index fc6349ecad..90927e1fa0 100644 --- a/src/course-outline/outline-sidebar/AddSidebar.test.tsx +++ b/src/course-outline/outline-sidebar/AddSidebar.test.tsx @@ -153,19 +153,19 @@ describe('AddSidebar component', () => { type: 'chapter', parentLocator: 'course-usage-key', displayName: 'Section', - }); + }, { onSuccess: expect.anything() }); await user.click(subsection); expect(handleAddSubsection.mutateAsync).toHaveBeenCalledWith({ type: 'sequential', parentLocator: lastSection.id, displayName: 'Subsection', - }); + }, { onSuccess: expect.anything() }); await user.click(unit); - expect(handleAddUnit.mutateAsync).toHaveBeenCalledWith({ + expect(handleAddAndOpenUnit.mutateAsync).toHaveBeenCalledWith({ type: 'vertical', parentLocator: lastSubsection.id, displayName: 'Unit', - }); + }, { onSettled: expect.anything() }); }); it('calls appropriate handlers on existing button click', async () => { @@ -181,12 +181,12 @@ describe('AddSidebar component', () => { const addBtns = await screen.findAllByRole('button', { name: 'Add' }); // first one is unit as per mock await user.click(addBtns[0]); - expect(handleAddAndOpenUnit.mutateAsync).toHaveBeenCalledWith({ + expect(handleAddUnit.mutateAsync).toHaveBeenCalledWith({ type: 'library_v2', category: 'vertical', parentLocator: lastSubsection.id, libraryContentKey: searchResult.results[0].hits[0].usage_key, - }); + }, { onSettled: expect.anything() }); // second one is subsection as per mock await user.click(addBtns[1]); expect(handleAddSubsection.mutateAsync).toHaveBeenCalledWith({ @@ -217,6 +217,8 @@ describe('AddSidebar component', () => { grandParentLocator: category === 'unit' ? firstSection.id : undefined, }; renderComponent(); + // Check existing tab content + await user.click(await screen.findByRole('tab', { name: 'Add Existing' })); // Check existing tab content is rendered by default await waitFor(() => { expect(fetchMock).toHaveFetchedTimes(1, searchEndpoint, 'post'); }); expect(fetchMock).toHaveLastFetched((_url, req) => { diff --git a/src/course-outline/outline-sidebar/OutlineSidebar.test.tsx b/src/course-outline/outline-sidebar/OutlineSidebar.test.tsx index 8ab935f9c9..c3d18ef982 100644 --- a/src/course-outline/outline-sidebar/OutlineSidebar.test.tsx +++ b/src/course-outline/outline-sidebar/OutlineSidebar.test.tsx @@ -14,6 +14,7 @@ import OutlineSidebar from './OutlineSidebar'; jest.mock('@src/course-outline/data/apiHooks', () => ({ useCourseDetails: jest.fn().mockReturnValue({ isPending: false, data: { title: 'Test Course' } }), useCreateCourseBlock: jest.fn(), + useCourseItemData: jest.fn().mockReturnValue({ data: {} }), })); const courseId = '123'; diff --git a/src/course-outline/section-card/SectionCard.test.tsx b/src/course-outline/section-card/SectionCard.test.tsx index d8c4feb433..ffe2859f9e 100644 --- a/src/course-outline/section-card/SectionCard.test.tsx +++ b/src/course-outline/section-card/SectionCard.test.tsx @@ -5,6 +5,7 @@ import { import { XBlock } from '@src/data/types'; import { Info } from '@openedx/paragon/icons'; import userEvent from '@testing-library/user-event'; +import { getXBlockApiUrl } from '@src/course-outline/data/api'; import SectionCard from './SectionCard'; import * as OutlineSidebarContext from '../outline-sidebar/OutlineSidebarContext'; import { CourseInfoSidebar } from '../outline-sidebar/CourseInfoSidebar'; @@ -51,9 +52,7 @@ const subsection = { isHeaderVisible: true, releasedToStudents: true, childInfo: { - children: [{ - id: unit.id, - }], + children: [unit], } as any, // 'as any' because we are omitting a lot of fields from 'childInfo' } satisfies Partial as XBlock; @@ -73,14 +72,7 @@ const section = { }, isHeaderVisible: true, childInfo: { - children: [{ - id: subsection.id, - childInfo: { - children: [{ - id: unit.id, - }], - }, - }], + children: [subsection], } as any, // 'as any' because we are omitting a lot of fields from 'childInfo' upstreamInfo: { readyToSync: true, @@ -121,10 +113,15 @@ const renderComponent = (props?: object, entry = '/course/:courseId') => render( extraWrapper: OutlineSidebarContext.OutlineSidebarProvider, }, ); +let axiosMock; describe('', () => { beforeEach(() => { - initializeMocks(); + const mocks = initializeMocks(); + axiosMock = mocks.axiosMock; + axiosMock + .onGet(getXBlockApiUrl(section.id)) + .reply(200, section); }); it('render SectionCard component correctly', () => { @@ -138,7 +135,8 @@ describe('', () => { expect(card).not.toHaveClass('outline-card-selected'); }); - it('render SectionCard component in selected state', () => { + it('render SectionCard component in selected state', async () => { + const user = userEvent.setup(); setConfig({ ...getConfig(), ENABLE_COURSE_OUTLINE_NEW_DESIGN: 'true', @@ -148,16 +146,15 @@ describe('', () => { expect(screen.getByTestId('section-card-header')).toBeInTheDocument(); // The card is not selected - const card = screen.getByTestId('section-card'); - expect(card).not.toHaveClass('outline-card-selected'); + expect((await screen.findByTestId('section-card'))).not.toHaveClass('outline-card-selected'); // Get the that contains the card and click it to select the card const el = container.querySelector('div.row.mx-0') as HTMLInputElement; expect(el).not.toBeNull(); - fireEvent.click(el!); + await user.click(el!); // The card is selected - expect(card).toHaveClass('outline-card-selected'); + expect(await screen.findByTestId('section-card')).toHaveClass('outline-card-selected'); }); it('expands/collapses the card when the expand button is clicked', () => { @@ -184,6 +181,17 @@ describe('', () => { }); it('hides add new, duplicate & delete option based on childAddable, duplicable & deletable action flag', async () => { + axiosMock + .onGet(getXBlockApiUrl(section.id)) + .reply(200, { + ...section, + actions: { + draggable: true, + childAddable: false, + deletable: false, + duplicable: false, + }, + }); renderComponent({ section: { ...section, diff --git a/src/course-outline/status-bar/StatusBar.test.tsx b/src/course-outline/status-bar/StatusBar.test.tsx index 8857de48bb..25d1fb3604 100644 --- a/src/course-outline/status-bar/StatusBar.test.tsx +++ b/src/course-outline/status-bar/StatusBar.test.tsx @@ -32,7 +32,7 @@ jest.mock('@src/course-libraries/data/apiHooks', () => ({ let mockHasChanges = false; jest.mock('@src/course-outline/data/apiHooks', () => ({ useCourseDetails: () => ({ - data: [{ hasChanges: mockHasChanges }], + data: { hasChanges: mockHasChanges }, isLoading: false, }), })); From 796e460f33fd7161653c3e27131c850492ff51fd Mon Sep 17 00:00:00 2001 From: Navin Karkera Date: Mon, 2 Feb 2026 19:37:20 +0530 Subject: [PATCH 34/60] test: add tests --- .../outline-sidebar/AddSidebar.test.tsx | 42 ++++ .../outline-sidebar/AddSidebar.tsx | 4 + .../outline-sidebar/InfoSidebar.test.tsx | 134 +++++++++++ .../LibraryReferenceCard.test.tsx | 215 ++++++++++++++++++ .../outline-sidebar/LibraryReferenceCard.tsx | 9 +- .../outline-sidebar/SectionInfoSidebar.tsx | 53 +---- .../outline-sidebar/SubsectionInfoSidebar.tsx | 53 +---- .../outline-sidebar/UnitInfoSidebar.tsx | 54 +---- .../outline-sidebar/sharedComponents.tsx | 57 +++++ src/generic/sidebar/SidebarSection.tsx | 4 +- src/generic/sidebar/SidebarTitle.tsx | 4 +- src/generic/sidebar/messages.ts | 5 + 12 files changed, 484 insertions(+), 150 deletions(-) create mode 100644 src/course-outline/outline-sidebar/InfoSidebar.test.tsx create mode 100644 src/course-outline/outline-sidebar/LibraryReferenceCard.test.tsx create mode 100644 src/course-outline/outline-sidebar/sharedComponents.tsx diff --git a/src/course-outline/outline-sidebar/AddSidebar.test.tsx b/src/course-outline/outline-sidebar/AddSidebar.test.tsx index 90927e1fa0..a2e723fa09 100644 --- a/src/course-outline/outline-sidebar/AddSidebar.test.tsx +++ b/src/course-outline/outline-sidebar/AddSidebar.test.tsx @@ -19,6 +19,7 @@ import { import fetchMock from 'fetch-mock-jest'; import type { ContainerType } from '@src/generic/key-utils'; import { AddSidebar } from './AddSidebar'; +import { XBlock } from '@src/data/types'; const handleAddSection = { mutateAsync: jest.fn() }; const handleAddSubsection = { mutateAsync: jest.fn() }; @@ -60,11 +61,19 @@ jest.mock('@src/studio-home/hooks', () => ({ })); let currentFlow: OutlineFlow | null = null; +let isCurrentFlowOn = false; +let currentItemData: Partial | null; +let clearSelection = jest.fn(); +let stopCurrentFlow = jest.fn(); jest.mock('../outline-sidebar/OutlineSidebarContext', () => ({ ...jest.requireActual('../outline-sidebar/OutlineSidebarContext'), useOutlineSidebarContext: () => ({ ...jest.requireActual('../outline-sidebar/OutlineSidebarContext').useOutlineSidebarContext(), currentFlow, + isCurrentFlowOn, + currentItemData, + clearSelection, + stopCurrentFlow, }), })); @@ -251,4 +260,37 @@ describe('AddSidebar component', () => { } }); }); + + it('shows alert when container cannot be added', async () => { + const user = userEvent.setup(); + currentItemData = { + displayName: 'Test container', + category: 'chapter', + actions: { + childAddable: false, + deletable: true, + draggable: true, + duplicable: true, + } + } + renderComponent(); + + // render existing tab as well + await user.click(await screen.findByRole('tab', { name: 'Add Existing' })); + // One in new tab and one in existing tab + expect((await screen.findAllByText( + `${currentItemData.displayName} is a library section. Content cannot be added to Library referenced sections.` + )).length).toEqual(2); + }); + + it('back button is rendered and works', async () => { + const user = userEvent.setup(); + isCurrentFlowOn = true; + renderComponent(); + + const back = await screen.findByRole('button', { name: 'Back' }); + await user.click(back); + expect(clearSelection).toHaveBeenCalled(); + expect(stopCurrentFlow).toHaveBeenCalled(); + }); }); diff --git a/src/course-outline/outline-sidebar/AddSidebar.tsx b/src/course-outline/outline-sidebar/AddSidebar.tsx index f0b5cad846..dbbfcb3fdc 100644 --- a/src/course-outline/outline-sidebar/AddSidebar.tsx +++ b/src/course-outline/outline-sidebar/AddSidebar.tsx @@ -72,6 +72,7 @@ const AddContentButton = ({ name, blockType } : AddContentButtonProps) => { displayName: COURSE_BLOCK_NAMES.chapter.name, }, { onSuccess: (data: { locator: string; }) => { + // istanbul ignore next if (onSuccess) { onSuccess(data); } else { @@ -88,6 +89,7 @@ const AddContentButton = ({ name, blockType } : AddContentButtonProps) => { displayName: COURSE_BLOCK_NAMES.sequential.name, }, { onSuccess: (data: { locator: string; }) => { + // istanbul ignore next if (onSuccess) { onSuccess(data); } else { @@ -104,6 +106,7 @@ const AddContentButton = ({ name, blockType } : AddContentButtonProps) => { displayName: COURSE_BLOCK_NAMES.vertical.name, }, { onSettled: () => { + // istanbul ignore next if (onSettled) { onSettled(); } else { @@ -286,6 +289,7 @@ const ShowLibraryContent = () => { libraryContentKey: usageKey, }, { onSettled: () => { + // istanbul ignore next queryClient.invalidateQueries({ queryKey: courseOutlineQueryKeys.courseItemId(sectionParentId), }); diff --git a/src/course-outline/outline-sidebar/InfoSidebar.test.tsx b/src/course-outline/outline-sidebar/InfoSidebar.test.tsx new file mode 100644 index 0000000000..ad369941c5 --- /dev/null +++ b/src/course-outline/outline-sidebar/InfoSidebar.test.tsx @@ -0,0 +1,134 @@ +import { InfoSidebar } from './InfoSidebar' +import { initializeMocks, render, screen } from '@src/testUtils' +import { SelectionState } from '@src/data/types'; +import { OutlineSidebarProvider } from '@src/course-outline/outline-sidebar/OutlineSidebarContext'; +import { getXBlockApiUrl } from '@src/course-outline/data/api'; +import userEvent from '@testing-library/user-event'; + +let selectedContainerState: SelectionState | undefined = undefined; +jest.mock('../outline-sidebar/OutlineSidebarContext', () => ({ + ...jest.requireActual('../outline-sidebar/OutlineSidebarContext'), + useOutlineSidebarContext: () => ({ + ...jest.requireActual('../outline-sidebar/OutlineSidebarContext').useOutlineSidebarContext(), + selectedContainerState, + }), +})); + +jest.mock('@src/course-outline/data/apiHooks', () => ({ + ...jest.requireActual('@src/course-outline/data/apiHooks'), + useCourseDetails: () => ({ + data: { title: 'Course name' }, + isLoading: false, + }), +})); + +const openPublishModal = jest.fn(); +jest.mock('@src/CourseAuthoringContext', () => ({ + useCourseAuthoringContext: () => ({ + courseId: 5, + setCurrentSelection: jest.fn(), + openPublishModal, + getUnitUrl: jest.fn(), + }), +})); + +jest.mock('@src/search-manager', () => ({ + useGetBlockTypes: () => ({ data: [] }), +})); + +const renderComponent = () => render(, { extraWrapper: OutlineSidebarProvider}); +let axiosMock; + +describe('InfoSidebar component', () => { + beforeEach(() => { + const mocks = initializeMocks(); + axiosMock = mocks.axiosMock; + }); + + it('renders InfoSidebar with course info if selectedContainerState is undefined', async () => { + renderComponent(); + expect(await screen.findByText('Course name')).toBeInTheDocument(); + }); + + it('renders InfoSidebar with section info', async () => { + const user = userEvent.setup(); + selectedContainerState = { + currentId: 'block-v1:UNIX+UX1+2025_T3+type@chapter+block@123' + } + const data = { + id: selectedContainerState.currentId, + displayName: 'section name', + category: 'chapter', + hasChanges: true, + } + axiosMock + .onGet(getXBlockApiUrl(selectedContainerState.currentId)) + .reply(200, data); + renderComponent(); + expect(await screen.findByText('section name')).toBeInTheDocument(); + expect(await screen.findByText('Section Content Summary')).toBeInTheDocument(); + const btn = await screen.findByRole('button', { name: 'Publish Changes (Draft)' }); + expect(btn).toBeInTheDocument(); + await user.click(btn); + expect(openPublishModal).toHaveBeenCalledWith({ + value: data, + sectionId: data.id, + }); + }); + + it('renders InfoSidebar with subsection info', async () => { + const user = userEvent.setup(); + selectedContainerState = { + currentId: 'block-v1:UNIX+UX1+2025_T3+type@sequential+block@123', + sectionId: 'block-v1:UNIX+UX1+2025_T3+type@chapter+block@123' + } + const data = { + id: selectedContainerState.currentId, + displayName: 'subsection name', + category: 'sequential', + hasChanges: true, + } + axiosMock + .onGet(getXBlockApiUrl(selectedContainerState.currentId)) + .reply(200, data); + renderComponent(); + expect(await screen.findByText('subsection name')).toBeInTheDocument(); + expect(await screen.findByText('Subsection Content Summary')).toBeInTheDocument(); + const btn = await screen.findByRole('button', { name: 'Publish Changes (Draft)' }); + expect(btn).toBeInTheDocument(); + await user.click(btn); + expect(openPublishModal).toHaveBeenCalledWith({ + value: data, + sectionId: selectedContainerState.sectionId, + }); + }); + + it('renders InfoSidebar with unit info', async () => { + const user = userEvent.setup(); + selectedContainerState = { + currentId: 'block-v1:UNIX+UX1+2025_T3+type@vertical+block@123', + subsectionId: 'block-v1:UNIX+UX1+2025_T3+type@sequential+block@123', + sectionId: 'block-v1:UNIX+UX1+2025_T3+type@chapter+block@123' + } + const data = { + id: selectedContainerState.currentId, + displayName: 'unit name', + category: 'vertical', + hasChanges: true, + } + axiosMock + .onGet(getXBlockApiUrl(selectedContainerState.currentId)) + .reply(200, data); + renderComponent(); + expect(await screen.findByText('unit name')).toBeInTheDocument(); + expect(await screen.findByText('Unit Content Summary')).toBeInTheDocument(); + const btn = await screen.findByRole('button', { name: 'Publish Changes (Draft)' }); + expect(btn).toBeInTheDocument(); + await user.click(btn); + expect(openPublishModal).toHaveBeenCalledWith({ + value: data, + subsectionId: selectedContainerState.subsectionId, + sectionId: selectedContainerState.sectionId, + }); + }); +}); diff --git a/src/course-outline/outline-sidebar/LibraryReferenceCard.test.tsx b/src/course-outline/outline-sidebar/LibraryReferenceCard.test.tsx new file mode 100644 index 0000000000..a71c863c88 --- /dev/null +++ b/src/course-outline/outline-sidebar/LibraryReferenceCard.test.tsx @@ -0,0 +1,215 @@ +import { getXBlockApiUrl } from '@src/course-outline/data/api'; +import { render, screen, initializeMocks } from '@src/testUtils'; +import { userEvent } from '@testing-library/user-event'; +import { LibraryReferenceCard } from './LibraryReferenceCard'; + +let axiosMock; +let upstreamData = { + id: 'lct:UNIX:CIT1:subsection:99d7e14e2d6f4ab7989dc0d948f917df', + name: 'upstream subsection', +} +let sectionData = { + id: 'block-v1:UNIX+UX1+2025_T3+type@chapter+block@asafd', + displayName: 'downstream section', + upstreamInfo: { + upstreamRef: 'lct:UNIX:CIT1:section:d12323', + upstreamName: 'upstream section', + downstreamKey: 'block-v1:UNIX+UX1+2025_T3+type@chapter+block@asafd', + versionAvailable: 2, + versionDeclined: null, + readyToSync: false, + topLevelParentKey: null, + downstreamCustomized: [], + } +} +let itemData = { + id: 'block-v1:UNIX+UX1+2025_T3+type@sequential+block@sequential45d4d95a', + displayName: 'downstream subsection', + upstreamInfo: { + upstreamRef: upstreamData.id, + upstreamName: upstreamData.name, + downstreamKey: 'block-v1:UNIX+UX1+2025_T3+type@sequential+block@sequential45d4d95a', + versionAvailable: 2, + versionDeclined: null, + readyToSync: false, + topLevelParentKey: null, + downstreamCustomized: [], + } +} + +const mockUseOutlineSidebarContext = jest.fn().mockReturnValue({ + selectedContainerState: { currentId: itemData.id, sectionId: sectionData.id }, + openContainerInfoSidebar: jest.fn(), +}); +const mockUseCourseAuthoringContext = jest.fn().mockReturnValue({ + openUnlinkModal: jest.fn(), + courseId: 'course1', +}); + +jest.mock('@src/course-outline/outline-sidebar/OutlineSidebarContext', () => ({ + useOutlineSidebarContext: () => mockUseOutlineSidebarContext(), +})); +jest.mock('@src/CourseAuthoringContext', () => ({ + useCourseAuthoringContext: () => mockUseCourseAuthoringContext(), +})); +const mockOpenSyncModal = jest.fn(); +jest.mock('@src/hooks', () => ({ + useToggleWithValue: () => [false, {}, mockOpenSyncModal, jest.fn()], +})); + +describe('LibraryReferenceCard', () => { + beforeEach(() => { + const mocks = initializeMocks(); + axiosMock = mocks.axiosMock; + axiosMock + .onGet(getXBlockApiUrl(sectionData.id)) + .reply(200, sectionData); + axiosMock + .onGet(getXBlockApiUrl(itemData.id)) + .reply(200, itemData); + }); + + it('renders the LibraryReferenceCard normally', async () => { + render(); + expect(await screen.findByText(/Library Reference/)).toBeInTheDocument(); + }); + + it('renders the LibraryReferenceCard error message', async () => { + const user = userEvent.setup(); + const data = { + ...itemData, + upstreamInfo: { + ...itemData.upstreamInfo, + errorMessage: 'some error', + } + }; + axiosMock + .onGet(getXBlockApiUrl(itemData.id)) + .reply(200, data); + render(); + expect(await screen.findByText( + `The link between ${itemData.displayName} and the library version has been broken. To edit or make changes, unlink component.` + )).toBeInTheDocument(); + + await user.click(await screen.findByRole('button', { name: 'Unlink from library' })); + expect(mockUseCourseAuthoringContext().openUnlinkModal).toHaveBeenCalledWith({ + value: data, + sectionId: sectionData.id, + }); + }); + + it('renders the LibraryReferenceCard ready to sync', async () => { + const user = userEvent.setup(); + axiosMock + .onGet(getXBlockApiUrl(itemData.id)) + .reply(200, { + ...itemData, + upstreamInfo: { + ...itemData.upstreamInfo, + readyToSync: true, + } + }); + render(); + expect(await screen.findByText( + `${itemData.displayName} has available updates` + )).toBeInTheDocument(); + + await user.click(await screen.findByRole('button', { name: 'Review Updates' })); + expect(mockOpenSyncModal).toHaveBeenCalled(); + }); + + it('renders the LibraryReferenceCard customized text', async () => { + axiosMock + .onGet(getXBlockApiUrl(itemData.id)) + .reply(200, { + ...itemData, + upstreamInfo: { + ...itemData.upstreamInfo, + downstreamCustomized: ['displayName'], + } + }); + render(); + expect(await screen.findByText( + `${itemData.displayName} has been modified in this course.` + )).toBeInTheDocument(); + }); + + it('renders the LibraryReferenceCard with top level error message', async () => { + const user = userEvent.setup(); + axiosMock + .onGet(getXBlockApiUrl(itemData.id)) + .reply(200, { + ...itemData, + upstreamInfo: { + ...itemData.upstreamInfo, + topLevelParentKey: sectionData.upstreamInfo.downstreamKey, + errorMessage: 'some error', + } + }); + render(); + expect(await screen.findByText( + `${itemData.displayName} was reused as part of a section which has a broken link. To recieve library updates to this component, unlink the broken link.` + )).toBeInTheDocument(); + + await user.click(await screen.findByRole('button', { name: 'Unlink section' })); + // should call unlink with parent section data + expect(mockUseCourseAuthoringContext().openUnlinkModal).toHaveBeenCalledWith({ + value: sectionData, + sectionId: sectionData.id, + }); + }); + + it('renders the LibraryReferenceCard with top level ready to sync', async () => { + const user = userEvent.setup(); + axiosMock + .onGet(getXBlockApiUrl(itemData.id)) + .reply(200, { + ...itemData, + upstreamInfo: { + ...itemData.upstreamInfo, + topLevelParentKey: sectionData.upstreamInfo.downstreamKey, + } + }); + const parentData = { + ...sectionData, + upstreamInfo: { + ...sectionData.upstreamInfo, + readyToSync: true, + } + } + axiosMock + .onGet(getXBlockApiUrl(sectionData.id)) + .reply(200, parentData); + render(); + expect(await screen.findByText( + `${itemData.displayName} was reused as part of a section which has updates available.` + )).toBeInTheDocument(); + + await user.click(await screen.findByRole('button', { name: 'Review Updates' })); + expect(mockOpenSyncModal).toHaveBeenCalledWith(parentData); + }); + + it('renders the LibraryReferenceCard with top level go to parent option', async () => { + const user = userEvent.setup(); + axiosMock + .onGet(getXBlockApiUrl(itemData.id)) + .reply(200, { + ...itemData, + upstreamInfo: { + ...itemData.upstreamInfo, + topLevelParentKey: sectionData.upstreamInfo.downstreamKey, + } + }); + render(); + expect(await screen.findByText( + `${itemData.displayName} was reused as part of a section.` + )).toBeInTheDocument(); + + await user.click(await screen.findByRole('button', { name: 'View section' })); + expect(mockUseOutlineSidebarContext().openContainerInfoSidebar).toHaveBeenCalledWith( + sectionData.id, + undefined, + sectionData.id + ); + }); +}); diff --git a/src/course-outline/outline-sidebar/LibraryReferenceCard.tsx b/src/course-outline/outline-sidebar/LibraryReferenceCard.tsx index 8655d043af..925431cd6a 100644 --- a/src/course-outline/outline-sidebar/LibraryReferenceCard.tsx +++ b/src/course-outline/outline-sidebar/LibraryReferenceCard.tsx @@ -25,12 +25,12 @@ interface SubProps { const HasTopParentTextAndButton = ({ blockData, displayName, openSyncModal }: SubProps) => { const { upstreamInfo } = blockData; - const { selectedContainerState } = useOutlineSidebarContext(); - const { openContainerInfoSidebar } = useOutlineSidebarContext(); + const { selectedContainerState, openContainerInfoSidebar } = useOutlineSidebarContext(); const { openUnlinkModal } = useCourseAuthoringContext(); const { data: parentData, isPending } = useCourseItemData(upstreamInfo?.topLevelParentKey); const handleUnlinkClick = () => { + // istanbul ignore if if (!selectedContainerState?.sectionId || !parentData) { return; } @@ -38,6 +38,7 @@ const HasTopParentTextAndButton = ({ blockData, displayName, openSyncModal }: Su }; const handleSyncClick = () => { + // istanbul ignore if if (!parentData) { return; } @@ -45,7 +46,7 @@ const HasTopParentTextAndButton = ({ blockData, displayName, openSyncModal }: Su }; const handleGoToParent = () => { - // istanbul ignore: to satisfy checker + // istanbul ignore if if (!upstreamInfo?.topLevelParentKey) { return null; } @@ -127,6 +128,7 @@ const TopLevelTextAndButton = ({ blockData, displayName, openSyncModal }: SubPro }; const handleUnlinkClick = () => { + // istanbul ignore if if (!selectedContainerState?.sectionId) { return; } @@ -203,6 +205,7 @@ export const LibraryReferenceCard = ({ itemId }: Props) => { }; }, [syncModalData]); + // istanbul ignore next const handleOnPostChangeSync = useCallback(() => { if (selectedContainerState?.sectionId) { dispatch(fetchCourseSectionQuery([selectedContainerState.sectionId])); diff --git a/src/course-outline/outline-sidebar/SectionInfoSidebar.tsx b/src/course-outline/outline-sidebar/SectionInfoSidebar.tsx index 71e0edd583..cdb5f1cd9b 100644 --- a/src/course-outline/outline-sidebar/SectionInfoSidebar.tsx +++ b/src/course-outline/outline-sidebar/SectionInfoSidebar.tsx @@ -1,66 +1,23 @@ import { useState } from 'react'; import { useIntl } from '@edx/frontend-platform/i18n'; -import { Tab, Tabs, useToggle } from '@openedx/paragon'; -import { SchoolOutline, Tag } from '@openedx/paragon/icons'; +import { Tab, Tabs } from '@openedx/paragon'; -import { ContentTagsDrawerSheet, ContentTagsSnippet } from '@src/content-tags-drawer'; -import { ComponentCountSnippet, getItemIcon } from '@src/generic/block-type-utils'; -import { useGetBlockTypes } from '@src/search-manager'; +import { getItemIcon } from '@src/generic/block-type-utils'; -import { SidebarContent, SidebarSection, SidebarTitle } from '@src/generic/sidebar'; +import { SidebarTitle } from '@src/generic/sidebar'; import { useCourseItemData } from '@src/course-outline/data/apiHooks'; import Loading from '@src/generic/Loading'; import { useCourseAuthoringContext } from '@src/CourseAuthoringContext'; import { useOutlineSidebarContext } from '@src/course-outline/outline-sidebar/OutlineSidebarContext'; import messages from './messages'; -import { LibraryReferenceCard } from './LibraryReferenceCard'; import { PublishButon } from './PublishButon'; +import { InfoSection } from '@src/course-outline/outline-sidebar/sharedComponents'; interface Props { sectionId: string; } -const SectionInfoSidebar = ({ sectionId }: Props) => { - const intl = useIntl(); - const { data: componentData } = useGetBlockTypes( - [`breadcrumbs.usage_key = "${sectionId}"`], - ); - - const [isManageTagsDrawerOpen, openManageTagsDrawer, closeManageTagsDrawer] = useToggle(false); - - return ( - <> - - - - {componentData && } - - - - - - - - ); -}; - export const SectionSidebar = ({ sectionId }: Props) => { const intl = useIntl(); const [tab, setTab] = useState<'info' | 'settings'>('info'); @@ -98,7 +55,7 @@ export const SectionSidebar = ({ sectionId }: Props) => { mountOnEnter > - +
Settings
diff --git a/src/course-outline/outline-sidebar/SubsectionInfoSidebar.tsx b/src/course-outline/outline-sidebar/SubsectionInfoSidebar.tsx index e6c35a10bd..2f100dcc37 100644 --- a/src/course-outline/outline-sidebar/SubsectionInfoSidebar.tsx +++ b/src/course-outline/outline-sidebar/SubsectionInfoSidebar.tsx @@ -1,66 +1,23 @@ import { useState } from 'react'; import { useIntl } from '@edx/frontend-platform/i18n'; -import { Tab, Tabs, useToggle } from '@openedx/paragon'; -import { SchoolOutline, Tag } from '@openedx/paragon/icons'; +import { Tab, Tabs } from '@openedx/paragon'; -import { ContentTagsDrawerSheet, ContentTagsSnippet } from '@src/content-tags-drawer'; -import { ComponentCountSnippet, getItemIcon } from '@src/generic/block-type-utils'; -import { useGetBlockTypes } from '@src/search-manager'; +import { getItemIcon } from '@src/generic/block-type-utils'; -import { SidebarContent, SidebarSection, SidebarTitle } from '@src/generic/sidebar'; +import { SidebarTitle } from '@src/generic/sidebar'; import { useCourseItemData } from '@src/course-outline/data/apiHooks'; import Loading from '@src/generic/Loading'; import { useCourseAuthoringContext } from '@src/CourseAuthoringContext'; import { useOutlineSidebarContext } from '@src/course-outline/outline-sidebar/OutlineSidebarContext'; -import { LibraryReferenceCard } from './LibraryReferenceCard'; import { PublishButon } from './PublishButon'; import messages from './messages'; +import { InfoSection } from '@src/course-outline/outline-sidebar/sharedComponents'; interface Props { subsectionId: string; } -const SubsectionInfoSidebar = ({ subsectionId }: Props) => { - const intl = useIntl(); - const { data: componentData } = useGetBlockTypes( - [`breadcrumbs.usage_key = "${subsectionId}"`], - ); - - const [isManageTagsDrawerOpen, openManageTagsDrawer, closeManageTagsDrawer] = useToggle(false); - - return ( - <> - - - - {componentData && } - - - - - - - - ); -}; - export const SubsectionSidebar = ({ subsectionId }: Props) => { const intl = useIntl(); const [tab, setTab] = useState<'info' | 'settings'>('info'); @@ -99,7 +56,7 @@ export const SubsectionSidebar = ({ subsectionId }: Props) => { mountOnEnter > - +
Settings
diff --git a/src/course-outline/outline-sidebar/UnitInfoSidebar.tsx b/src/course-outline/outline-sidebar/UnitInfoSidebar.tsx index 6cf8c9822b..0b8b453c70 100644 --- a/src/course-outline/outline-sidebar/UnitInfoSidebar.tsx +++ b/src/course-outline/outline-sidebar/UnitInfoSidebar.tsx @@ -1,17 +1,15 @@ import { useState } from 'react'; import { useIntl } from '@edx/frontend-platform/i18n'; import { - Button, Stack, Tab, Tabs, useToggle, + Button, Stack, Tab, Tabs, } from '@openedx/paragon'; import { - OpenInFull, SchoolOutline, Tag, + OpenInFull, } from '@openedx/paragon/icons'; -import { ContentTagsDrawerSheet, ContentTagsSnippet } from '@src/content-tags-drawer'; -import { ComponentCountSnippet, getItemIcon } from '@src/generic/block-type-utils'; -import { useGetBlockTypes } from '@src/search-manager'; +import { getItemIcon } from '@src/generic/block-type-utils'; -import { SidebarContent, SidebarSection, SidebarTitle } from '@src/generic/sidebar'; +import { SidebarTitle } from '@src/generic/sidebar'; import { useCourseItemData } from '@src/course-outline/data/apiHooks'; import Loading from '@src/generic/Loading'; @@ -21,53 +19,13 @@ import { IframeProvider } from '@src/generic/hooks/context/iFrameContext'; import { Link } from 'react-router-dom'; import { useOutlineSidebarContext } from './OutlineSidebarContext'; import { PublishButon } from './PublishButon'; -import { LibraryReferenceCard } from './LibraryReferenceCard'; import messages from './messages'; +import { InfoSection } from './sharedComponents'; interface Props { unitId: string; } -const UnitInfoSidebar = ({ unitId }: Props) => { - const intl = useIntl(); - const { data: componentData } = useGetBlockTypes( - [`breadcrumbs.usage_key = "${unitId}"`], - ); - - const [isManageTagsDrawerOpen, openManageTagsDrawer, closeManageTagsDrawer] = useToggle(false); - - return ( - <> - - - - {componentData && } - - - - - - - - ); -}; - export const UnitSidebar = ({ unitId }: Props) => { const intl = useIntl(); const [tab, setTab] = useState<'preview' | 'info' | 'settings'>('info'); @@ -137,7 +95,7 @@ export const UnitSidebar = ({ unitId }: Props) => {
- +
Settings
diff --git a/src/course-outline/outline-sidebar/sharedComponents.tsx b/src/course-outline/outline-sidebar/sharedComponents.tsx new file mode 100644 index 0000000000..a2b36588da --- /dev/null +++ b/src/course-outline/outline-sidebar/sharedComponents.tsx @@ -0,0 +1,57 @@ +import { useIntl } from "@edx/frontend-platform/i18n"; +import { useToggle } from "@openedx/paragon"; +import { SchoolOutline, Tag } from "@openedx/paragon/icons"; +import { ContentTagsDrawerSheet, ContentTagsSnippet } from "@src/content-tags-drawer"; +import { useCourseItemData } from "@src/course-outline/data/apiHooks"; +import { LibraryReferenceCard } from "@src/course-outline/outline-sidebar/LibraryReferenceCard"; +import { ComponentCountSnippet, getItemIcon } from "@src/generic/block-type-utils"; +import { normalizeContainerType } from "@src/generic/key-utils"; +import { SidebarContent, SidebarSection } from "@src/generic/sidebar"; +import { useGetBlockTypes } from "@src/search-manager"; +import messages from './messages'; + +interface Props { + itemId: string; +} + +export const InfoSection = ({ itemId }: Props) => { + const intl = useIntl(); + const { data: itemData } = useCourseItemData(itemId); + const { data: componentData } = useGetBlockTypes( + [`breadcrumbs.usage_key = "${itemId}"`], + ); + const category = normalizeContainerType(itemData?.category || ''); + + const [isManageTagsDrawerOpen, openManageTagsDrawer, closeManageTagsDrawer] = useToggle(false); + + return ( + <> + + + + {componentData && } + + + + + + + + ); +}; diff --git a/src/generic/sidebar/SidebarSection.tsx b/src/generic/sidebar/SidebarSection.tsx index 7f407f7449..d37b8a9253 100644 --- a/src/generic/sidebar/SidebarSection.tsx +++ b/src/generic/sidebar/SidebarSection.tsx @@ -48,9 +48,9 @@ export const SidebarSection = ({ {icon && } {title && ( -

+

{title} -
+ )} {actions && ( diff --git a/src/generic/sidebar/SidebarTitle.tsx b/src/generic/sidebar/SidebarTitle.tsx index c13abfd848..1ff5df366d 100644 --- a/src/generic/sidebar/SidebarTitle.tsx +++ b/src/generic/sidebar/SidebarTitle.tsx @@ -1,5 +1,7 @@ +import { useIntl } from '@edx/frontend-platform/i18n'; import { Icon, IconButton, Stack } from '@openedx/paragon'; import { ArrowBack } from '@openedx/paragon/icons'; +import messages from './messages'; interface SidebarTitleProps { /** Title of the section */ @@ -27,7 +29,7 @@ export const SidebarTitle = ({ {onBackBtnClick && ( diff --git a/src/generic/sidebar/messages.ts b/src/generic/sidebar/messages.ts index 05657c30a4..869e12d509 100644 --- a/src/generic/sidebar/messages.ts +++ b/src/generic/sidebar/messages.ts @@ -6,6 +6,11 @@ const messages = defineMessages({ defaultMessage: 'Toggle', description: 'Toggle button alt', }, + backBtnText: { + id: 'course-authoring.sidebar.back.btn.alt-text', + defaultMessage: 'Back', + description: 'Alternate text of Back button in sidebar title', + }, }); export default messages; From 45dedab4acced0ac53264a4d1986e3b1405a30e8 Mon Sep 17 00:00:00 2001 From: Navin Karkera Date: Mon, 2 Feb 2026 20:03:55 +0530 Subject: [PATCH 35/60] refactor: add sidebar tests --- .../outline-sidebar/AddSidebar.test.tsx | 64 +++++++++++-------- 1 file changed, 39 insertions(+), 25 deletions(-) diff --git a/src/course-outline/outline-sidebar/AddSidebar.test.tsx b/src/course-outline/outline-sidebar/AddSidebar.test.tsx index a2e723fa09..923a712af8 100644 --- a/src/course-outline/outline-sidebar/AddSidebar.test.tsx +++ b/src/course-outline/outline-sidebar/AddSidebar.test.tsx @@ -20,11 +20,10 @@ import fetchMock from 'fetch-mock-jest'; import type { ContainerType } from '@src/generic/key-utils'; import { AddSidebar } from './AddSidebar'; import { XBlock } from '@src/data/types'; +import { CourseAuthoringProvider } from '@src/CourseAuthoringContext'; +import { snakeCaseKeys } from '@src/editors/utils'; +import { getXBlockBaseApiUrl } from '@src/course-outline/data/api'; -const handleAddSection = { mutateAsync: jest.fn() }; -const handleAddSubsection = { mutateAsync: jest.fn() }; -const handleAddUnit = { mutateAsync: jest.fn() }; -const handleAddAndOpenUnit = { mutateAsync: jest.fn() }; mockContentSearchConfig.applyMock(); mockContentLibrary.applyMock(); mockGetCollectionMetadata.applyMock(); @@ -35,14 +34,12 @@ mockGetContainerMetadata.applyMock(); const searchEndpoint = 'http://mock.meilisearch.local/multi-search'; const setCurrentSelection = jest.fn(); jest.mock('@src/CourseAuthoringContext', () => ({ + ...jest.requireActual('@src/CourseAuthoringContext'), useCourseAuthoringContext: () => ({ + ...jest.requireActual('@src/CourseAuthoringContext').useCourseAuthoringContext(), courseId: 5, - courseUsageKey: 'course-usage-key', + courseUsageKey: 'block-v1:UNIX+UX1+2025_T3+type@course+block@course', courseDetails: { name: 'Test course' }, - handleAddSection, - handleAddSubsection, - handleAddUnit, - handleAddAndOpenUnit, setCurrentSelection, }), })); @@ -77,7 +74,15 @@ jest.mock('../outline-sidebar/OutlineSidebarContext', () => ({ }), })); -const renderComponent = () => render(, { extraWrapper: OutlineSidebarProvider }); +const renderComponent = () => render(, { + extraWrapper: ({children}) => ( + + + {children} + + + ), +}); const searchResult = { ...mockResult, results: [ @@ -90,10 +95,12 @@ const searchResult = { }, ], }; +let axiosMock; describe('AddSidebar component', () => { beforeEach(() => { - initializeMocks(); + const mocks = initializeMocks(); + axiosMock = mocks.axiosMock; // The Meilisearch client-side API uses fetch, not Axios. fetchMock.mockReset(); fetchMock.post(searchEndpoint, (_url, req) => { @@ -151,6 +158,8 @@ describe('AddSidebar component', () => { const sectionList = courseOutlineIndexMock.courseStructure.childInfo.children; const lastSection = sectionList[3]; const lastSubsection = lastSection.childInfo.children[0]; + axiosMock.onPost(getXBlockBaseApiUrl()) + .reply(200, { locator: 'block-v1:UNIX+UX1+2025_T3+type@sequential+block@sequential45d4d95a' }); renderComponent(); // Validate handler for adding section, subsection and unit @@ -158,23 +167,26 @@ describe('AddSidebar component', () => { const subsection = await screen.findByRole('button', { name: 'Subsection' }); const unit = await screen.findByRole('button', { name: 'Unit' }); await user.click(section); - expect(handleAddSection.mutateAsync).toHaveBeenCalledWith({ + expect(axiosMock.history.post[0].data).toEqual(JSON.stringify(snakeCaseKeys({ type: 'chapter', - parentLocator: 'course-usage-key', + category: 'chapter', + parentLocator: 'block-v1:UNIX+UX1+2025_T3+type@course+block@course', displayName: 'Section', - }, { onSuccess: expect.anything() }); + }))); await user.click(subsection); - expect(handleAddSubsection.mutateAsync).toHaveBeenCalledWith({ + expect(axiosMock.history.post[1].data).toEqual(JSON.stringify(snakeCaseKeys({ type: 'sequential', + category: 'sequential', parentLocator: lastSection.id, displayName: 'Subsection', - }, { onSuccess: expect.anything() }); + }))); await user.click(unit); - expect(handleAddAndOpenUnit.mutateAsync).toHaveBeenCalledWith({ + expect(axiosMock.history.post[2].data).toEqual(JSON.stringify(snakeCaseKeys({ type: 'vertical', + category: 'vertical', parentLocator: lastSubsection.id, displayName: 'Unit', - }, { onSettled: expect.anything() }); + }))); }); it('calls appropriate handlers on existing button click', async () => { @@ -182,6 +194,8 @@ describe('AddSidebar component', () => { const sectionList = courseOutlineIndexMock.courseStructure.childInfo.children; const lastSection = sectionList[3]; const lastSubsection = lastSection.childInfo.children[0]; + axiosMock.onPost(getXBlockBaseApiUrl()) + .reply(200, { locator: 'block-v1:UNIX+UX1+2025_T3+type@sequential+block@sequential45d4d95a' }); renderComponent(); // Check existing tab content await user.click(await screen.findByRole('tab', { name: 'Add Existing' })); @@ -190,28 +204,28 @@ describe('AddSidebar component', () => { const addBtns = await screen.findAllByRole('button', { name: 'Add' }); // first one is unit as per mock await user.click(addBtns[0]); - expect(handleAddUnit.mutateAsync).toHaveBeenCalledWith({ + expect(axiosMock.history.post[0].data).toEqual(JSON.stringify(snakeCaseKeys({ type: 'library_v2', category: 'vertical', parentLocator: lastSubsection.id, libraryContentKey: searchResult.results[0].hits[0].usage_key, - }, { onSettled: expect.anything() }); + }))); // second one is subsection as per mock await user.click(addBtns[1]); - expect(handleAddSubsection.mutateAsync).toHaveBeenCalledWith({ + expect(axiosMock.history.post[1].data).toEqual(JSON.stringify(snakeCaseKeys({ type: 'library_v2', category: 'sequential', parentLocator: lastSection.id, libraryContentKey: searchResult.results[0].hits[1].usage_key, - }); + }))); // third one is section as per mock await user.click(addBtns[2]); - expect(handleAddSection.mutateAsync).toHaveBeenCalledWith({ + expect(axiosMock.history.post[2].data).toEqual(JSON.stringify(snakeCaseKeys({ type: 'library_v2', category: 'chapter', - parentLocator: 'course-usage-key', + parentLocator: 'block-v1:UNIX+UX1+2025_T3+type@course+block@course', libraryContentKey: searchResult.results[0].hits[2].usage_key, - }); + }))); }); ['section', 'subsection', 'unit'].forEach((category) => { From 922d40a77ee337946dc3a6f6162e886a53ffdea9 Mon Sep 17 00:00:00 2001 From: Navin Karkera Date: Mon, 2 Feb 2026 20:06:15 +0530 Subject: [PATCH 36/60] fix: lint issues --- .../outline-sidebar/AddSidebar.test.tsx | 16 +++---- .../outline-sidebar/InfoSidebar.test.tsx | 26 +++++------ .../LibraryReferenceCard.test.tsx | 46 +++++++++---------- .../outline-sidebar/SectionInfoSidebar.tsx | 2 +- .../outline-sidebar/SubsectionInfoSidebar.tsx | 2 +- .../outline-sidebar/sharedComponents.tsx | 20 ++++---- src/generic/sidebar/SidebarTitle.tsx | 31 +++++++------ 7 files changed, 73 insertions(+), 70 deletions(-) diff --git a/src/course-outline/outline-sidebar/AddSidebar.test.tsx b/src/course-outline/outline-sidebar/AddSidebar.test.tsx index 923a712af8..f2487815b8 100644 --- a/src/course-outline/outline-sidebar/AddSidebar.test.tsx +++ b/src/course-outline/outline-sidebar/AddSidebar.test.tsx @@ -18,11 +18,11 @@ import { } from '@src/course-outline/outline-sidebar/OutlineSidebarContext'; import fetchMock from 'fetch-mock-jest'; import type { ContainerType } from '@src/generic/key-utils'; -import { AddSidebar } from './AddSidebar'; import { XBlock } from '@src/data/types'; import { CourseAuthoringProvider } from '@src/CourseAuthoringContext'; import { snakeCaseKeys } from '@src/editors/utils'; import { getXBlockBaseApiUrl } from '@src/course-outline/data/api'; +import { AddSidebar } from './AddSidebar'; mockContentSearchConfig.applyMock(); mockContentLibrary.applyMock(); @@ -60,8 +60,8 @@ jest.mock('@src/studio-home/hooks', () => ({ let currentFlow: OutlineFlow | null = null; let isCurrentFlowOn = false; let currentItemData: Partial | null; -let clearSelection = jest.fn(); -let stopCurrentFlow = jest.fn(); +const clearSelection = jest.fn(); +const stopCurrentFlow = jest.fn(); jest.mock('../outline-sidebar/OutlineSidebarContext', () => ({ ...jest.requireActual('../outline-sidebar/OutlineSidebarContext'), useOutlineSidebarContext: () => ({ @@ -75,8 +75,8 @@ jest.mock('../outline-sidebar/OutlineSidebarContext', () => ({ })); const renderComponent = () => render(, { - extraWrapper: ({children}) => ( - + extraWrapper: ({ children }) => ( + {children} @@ -285,15 +285,15 @@ describe('AddSidebar component', () => { deletable: true, draggable: true, duplicable: true, - } - } + }, + }; renderComponent(); // render existing tab as well await user.click(await screen.findByRole('tab', { name: 'Add Existing' })); // One in new tab and one in existing tab expect((await screen.findAllByText( - `${currentItemData.displayName} is a library section. Content cannot be added to Library referenced sections.` + `${currentItemData.displayName} is a library section. Content cannot be added to Library referenced sections.`, )).length).toEqual(2); }); diff --git a/src/course-outline/outline-sidebar/InfoSidebar.test.tsx b/src/course-outline/outline-sidebar/InfoSidebar.test.tsx index ad369941c5..75de7fcbc8 100644 --- a/src/course-outline/outline-sidebar/InfoSidebar.test.tsx +++ b/src/course-outline/outline-sidebar/InfoSidebar.test.tsx @@ -1,11 +1,11 @@ -import { InfoSidebar } from './InfoSidebar' -import { initializeMocks, render, screen } from '@src/testUtils' +import { initializeMocks, render, screen } from '@src/testUtils'; import { SelectionState } from '@src/data/types'; import { OutlineSidebarProvider } from '@src/course-outline/outline-sidebar/OutlineSidebarContext'; import { getXBlockApiUrl } from '@src/course-outline/data/api'; import userEvent from '@testing-library/user-event'; +import { InfoSidebar } from './InfoSidebar'; -let selectedContainerState: SelectionState | undefined = undefined; +let selectedContainerState: SelectionState | undefined; jest.mock('../outline-sidebar/OutlineSidebarContext', () => ({ ...jest.requireActual('../outline-sidebar/OutlineSidebarContext'), useOutlineSidebarContext: () => ({ @@ -36,7 +36,7 @@ jest.mock('@src/search-manager', () => ({ useGetBlockTypes: () => ({ data: [] }), })); -const renderComponent = () => render(, { extraWrapper: OutlineSidebarProvider}); +const renderComponent = () => render(, { extraWrapper: OutlineSidebarProvider }); let axiosMock; describe('InfoSidebar component', () => { @@ -53,14 +53,14 @@ describe('InfoSidebar component', () => { it('renders InfoSidebar with section info', async () => { const user = userEvent.setup(); selectedContainerState = { - currentId: 'block-v1:UNIX+UX1+2025_T3+type@chapter+block@123' - } + currentId: 'block-v1:UNIX+UX1+2025_T3+type@chapter+block@123', + }; const data = { id: selectedContainerState.currentId, displayName: 'section name', category: 'chapter', hasChanges: true, - } + }; axiosMock .onGet(getXBlockApiUrl(selectedContainerState.currentId)) .reply(200, data); @@ -80,14 +80,14 @@ describe('InfoSidebar component', () => { const user = userEvent.setup(); selectedContainerState = { currentId: 'block-v1:UNIX+UX1+2025_T3+type@sequential+block@123', - sectionId: 'block-v1:UNIX+UX1+2025_T3+type@chapter+block@123' - } + sectionId: 'block-v1:UNIX+UX1+2025_T3+type@chapter+block@123', + }; const data = { id: selectedContainerState.currentId, displayName: 'subsection name', category: 'sequential', hasChanges: true, - } + }; axiosMock .onGet(getXBlockApiUrl(selectedContainerState.currentId)) .reply(200, data); @@ -108,14 +108,14 @@ describe('InfoSidebar component', () => { selectedContainerState = { currentId: 'block-v1:UNIX+UX1+2025_T3+type@vertical+block@123', subsectionId: 'block-v1:UNIX+UX1+2025_T3+type@sequential+block@123', - sectionId: 'block-v1:UNIX+UX1+2025_T3+type@chapter+block@123' - } + sectionId: 'block-v1:UNIX+UX1+2025_T3+type@chapter+block@123', + }; const data = { id: selectedContainerState.currentId, displayName: 'unit name', category: 'vertical', hasChanges: true, - } + }; axiosMock .onGet(getXBlockApiUrl(selectedContainerState.currentId)) .reply(200, data); diff --git a/src/course-outline/outline-sidebar/LibraryReferenceCard.test.tsx b/src/course-outline/outline-sidebar/LibraryReferenceCard.test.tsx index a71c863c88..01720903ca 100644 --- a/src/course-outline/outline-sidebar/LibraryReferenceCard.test.tsx +++ b/src/course-outline/outline-sidebar/LibraryReferenceCard.test.tsx @@ -4,11 +4,11 @@ import { userEvent } from '@testing-library/user-event'; import { LibraryReferenceCard } from './LibraryReferenceCard'; let axiosMock; -let upstreamData = { +const upstreamData = { id: 'lct:UNIX:CIT1:subsection:99d7e14e2d6f4ab7989dc0d948f917df', name: 'upstream subsection', -} -let sectionData = { +}; +const sectionData = { id: 'block-v1:UNIX+UX1+2025_T3+type@chapter+block@asafd', displayName: 'downstream section', upstreamInfo: { @@ -20,9 +20,9 @@ let sectionData = { readyToSync: false, topLevelParentKey: null, downstreamCustomized: [], - } -} -let itemData = { + }, +}; +const itemData = { id: 'block-v1:UNIX+UX1+2025_T3+type@sequential+block@sequential45d4d95a', displayName: 'downstream subsection', upstreamInfo: { @@ -34,8 +34,8 @@ let itemData = { readyToSync: false, topLevelParentKey: null, downstreamCustomized: [], - } -} + }, +}; const mockUseOutlineSidebarContext = jest.fn().mockReturnValue({ selectedContainerState: { currentId: itemData.id, sectionId: sectionData.id }, @@ -81,14 +81,14 @@ describe('LibraryReferenceCard', () => { upstreamInfo: { ...itemData.upstreamInfo, errorMessage: 'some error', - } + }, }; axiosMock .onGet(getXBlockApiUrl(itemData.id)) .reply(200, data); render(); expect(await screen.findByText( - `The link between ${itemData.displayName} and the library version has been broken. To edit or make changes, unlink component.` + `The link between ${itemData.displayName} and the library version has been broken. To edit or make changes, unlink component.`, )).toBeInTheDocument(); await user.click(await screen.findByRole('button', { name: 'Unlink from library' })); @@ -107,11 +107,11 @@ describe('LibraryReferenceCard', () => { upstreamInfo: { ...itemData.upstreamInfo, readyToSync: true, - } + }, }); render(); expect(await screen.findByText( - `${itemData.displayName} has available updates` + `${itemData.displayName} has available updates`, )).toBeInTheDocument(); await user.click(await screen.findByRole('button', { name: 'Review Updates' })); @@ -126,11 +126,11 @@ describe('LibraryReferenceCard', () => { upstreamInfo: { ...itemData.upstreamInfo, downstreamCustomized: ['displayName'], - } + }, }); render(); expect(await screen.findByText( - `${itemData.displayName} has been modified in this course.` + `${itemData.displayName} has been modified in this course.`, )).toBeInTheDocument(); }); @@ -144,11 +144,11 @@ describe('LibraryReferenceCard', () => { ...itemData.upstreamInfo, topLevelParentKey: sectionData.upstreamInfo.downstreamKey, errorMessage: 'some error', - } + }, }); render(); expect(await screen.findByText( - `${itemData.displayName} was reused as part of a section which has a broken link. To recieve library updates to this component, unlink the broken link.` + `${itemData.displayName} was reused as part of a section which has a broken link. To recieve library updates to this component, unlink the broken link.`, )).toBeInTheDocument(); await user.click(await screen.findByRole('button', { name: 'Unlink section' })); @@ -168,21 +168,21 @@ describe('LibraryReferenceCard', () => { upstreamInfo: { ...itemData.upstreamInfo, topLevelParentKey: sectionData.upstreamInfo.downstreamKey, - } + }, }); const parentData = { ...sectionData, upstreamInfo: { ...sectionData.upstreamInfo, readyToSync: true, - } - } + }, + }; axiosMock .onGet(getXBlockApiUrl(sectionData.id)) .reply(200, parentData); render(); expect(await screen.findByText( - `${itemData.displayName} was reused as part of a section which has updates available.` + `${itemData.displayName} was reused as part of a section which has updates available.`, )).toBeInTheDocument(); await user.click(await screen.findByRole('button', { name: 'Review Updates' })); @@ -198,18 +198,18 @@ describe('LibraryReferenceCard', () => { upstreamInfo: { ...itemData.upstreamInfo, topLevelParentKey: sectionData.upstreamInfo.downstreamKey, - } + }, }); render(); expect(await screen.findByText( - `${itemData.displayName} was reused as part of a section.` + `${itemData.displayName} was reused as part of a section.`, )).toBeInTheDocument(); await user.click(await screen.findByRole('button', { name: 'View section' })); expect(mockUseOutlineSidebarContext().openContainerInfoSidebar).toHaveBeenCalledWith( sectionData.id, undefined, - sectionData.id + sectionData.id, ); }); }); diff --git a/src/course-outline/outline-sidebar/SectionInfoSidebar.tsx b/src/course-outline/outline-sidebar/SectionInfoSidebar.tsx index cdb5f1cd9b..331e9224c1 100644 --- a/src/course-outline/outline-sidebar/SectionInfoSidebar.tsx +++ b/src/course-outline/outline-sidebar/SectionInfoSidebar.tsx @@ -10,9 +10,9 @@ import { useCourseItemData } from '@src/course-outline/data/apiHooks'; import Loading from '@src/generic/Loading'; import { useCourseAuthoringContext } from '@src/CourseAuthoringContext'; import { useOutlineSidebarContext } from '@src/course-outline/outline-sidebar/OutlineSidebarContext'; +import { InfoSection } from '@src/course-outline/outline-sidebar/sharedComponents'; import messages from './messages'; import { PublishButon } from './PublishButon'; -import { InfoSection } from '@src/course-outline/outline-sidebar/sharedComponents'; interface Props { sectionId: string; diff --git a/src/course-outline/outline-sidebar/SubsectionInfoSidebar.tsx b/src/course-outline/outline-sidebar/SubsectionInfoSidebar.tsx index 2f100dcc37..945c9ab4ee 100644 --- a/src/course-outline/outline-sidebar/SubsectionInfoSidebar.tsx +++ b/src/course-outline/outline-sidebar/SubsectionInfoSidebar.tsx @@ -10,9 +10,9 @@ import { useCourseItemData } from '@src/course-outline/data/apiHooks'; import Loading from '@src/generic/Loading'; import { useCourseAuthoringContext } from '@src/CourseAuthoringContext'; import { useOutlineSidebarContext } from '@src/course-outline/outline-sidebar/OutlineSidebarContext'; +import { InfoSection } from '@src/course-outline/outline-sidebar/sharedComponents'; import { PublishButon } from './PublishButon'; import messages from './messages'; -import { InfoSection } from '@src/course-outline/outline-sidebar/sharedComponents'; interface Props { subsectionId: string; diff --git a/src/course-outline/outline-sidebar/sharedComponents.tsx b/src/course-outline/outline-sidebar/sharedComponents.tsx index a2b36588da..cc204ddac5 100644 --- a/src/course-outline/outline-sidebar/sharedComponents.tsx +++ b/src/course-outline/outline-sidebar/sharedComponents.tsx @@ -1,13 +1,13 @@ -import { useIntl } from "@edx/frontend-platform/i18n"; -import { useToggle } from "@openedx/paragon"; -import { SchoolOutline, Tag } from "@openedx/paragon/icons"; -import { ContentTagsDrawerSheet, ContentTagsSnippet } from "@src/content-tags-drawer"; -import { useCourseItemData } from "@src/course-outline/data/apiHooks"; -import { LibraryReferenceCard } from "@src/course-outline/outline-sidebar/LibraryReferenceCard"; -import { ComponentCountSnippet, getItemIcon } from "@src/generic/block-type-utils"; -import { normalizeContainerType } from "@src/generic/key-utils"; -import { SidebarContent, SidebarSection } from "@src/generic/sidebar"; -import { useGetBlockTypes } from "@src/search-manager"; +import { useIntl } from '@edx/frontend-platform/i18n'; +import { useToggle } from '@openedx/paragon'; +import { SchoolOutline, Tag } from '@openedx/paragon/icons'; +import { ContentTagsDrawerSheet, ContentTagsSnippet } from '@src/content-tags-drawer'; +import { useCourseItemData } from '@src/course-outline/data/apiHooks'; +import { LibraryReferenceCard } from '@src/course-outline/outline-sidebar/LibraryReferenceCard'; +import { ComponentCountSnippet, getItemIcon } from '@src/generic/block-type-utils'; +import { normalizeContainerType } from '@src/generic/key-utils'; +import { SidebarContent, SidebarSection } from '@src/generic/sidebar'; +import { useGetBlockTypes } from '@src/search-manager'; import messages from './messages'; interface Props { diff --git a/src/generic/sidebar/SidebarTitle.tsx b/src/generic/sidebar/SidebarTitle.tsx index 1ff5df366d..a565e31eb9 100644 --- a/src/generic/sidebar/SidebarTitle.tsx +++ b/src/generic/sidebar/SidebarTitle.tsx @@ -24,17 +24,20 @@ export const SidebarTitle = ({ title, icon, onBackBtnClick, -}: SidebarTitleProps) => ( - - {onBackBtnClick && ( - - )} - -

{title}

-
-); +}: SidebarTitleProps) => { + const intl = useIntl(); + return ( + + {onBackBtnClick && ( + + )} + +

{title}

+
+ ); +}; From cddfe7cb5592786e934d2a90572f7c86a72de0e8 Mon Sep 17 00:00:00 2001 From: Navin Karkera Date: Mon, 2 Feb 2026 20:23:03 +0530 Subject: [PATCH 37/60] fix: test --- src/course-outline/CourseOutline.test.tsx | 5 +++-- .../outline-sidebar/OutlineAlignSidebar.test.tsx | 3 ++- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/src/course-outline/CourseOutline.test.tsx b/src/course-outline/CourseOutline.test.tsx index 85f1277010..5dde5e783e 100644 --- a/src/course-outline/CourseOutline.test.tsx +++ b/src/course-outline/CourseOutline.test.tsx @@ -70,7 +70,7 @@ const courseId = '123'; const getContainerKey = jest.fn().mockReturnValue('lct:org:lib:unit:1'); const getContainerType = jest.fn().mockReturnValue('unit'); const clearSelection = jest.fn(); -let selectedContainerId: string; +let selectedContainerId: string | undefined; window.HTMLElement.prototype.scrollIntoView = jest.fn(); jest.mock('@src/course-outline/outline-sidebar/OutlineSidebarContext', () => ({ @@ -78,7 +78,7 @@ jest.mock('@src/course-outline/outline-sidebar/OutlineSidebarContext', () => ({ useOutlineSidebarContext: () => ({ ...jest.requireActual('@src/course-outline/outline-sidebar/OutlineSidebarContext').useOutlineSidebarContext(), clearSelection, - selectedContainerState: { currentId: selectedContainerId }, + selectedContainerState: (() => (selectedContainerId ? { currentId: selectedContainerId } : undefined))(), }), })); @@ -162,6 +162,7 @@ const renderComponent = () => render( describe('', () => { beforeEach(async () => { const mocks = initializeMocks(); + selectedContainerId = undefined; jest.mocked(useLocation).mockReturnValue({ pathname: mockPathname, diff --git a/src/course-outline/outline-sidebar/OutlineAlignSidebar.test.tsx b/src/course-outline/outline-sidebar/OutlineAlignSidebar.test.tsx index 3188294d96..5339021682 100644 --- a/src/course-outline/outline-sidebar/OutlineAlignSidebar.test.tsx +++ b/src/course-outline/outline-sidebar/OutlineAlignSidebar.test.tsx @@ -1,4 +1,4 @@ -import { render, screen } from '@testing-library/react'; +import { initializeMocks, render, screen } from '@src/testUtils'; import * as CourseAuthoringContext from '@src/CourseAuthoringContext'; import * as CourseDetailsApi from '@src/data/apiHooks'; @@ -16,6 +16,7 @@ jest.mock('@src/content-tags-drawer', () => ({ describe('OutlineAlignSidebar', () => { beforeEach(() => { + initializeMocks(); jest .spyOn(CourseAuthoringContext, 'useCourseAuthoringContext') .mockReturnValue({ From 5b90b1634b4a7320d96442dcb2204e07099ebf7e Mon Sep 17 00:00:00 2001 From: Navin Karkera Date: Tue, 3 Feb 2026 11:03:12 +0530 Subject: [PATCH 38/60] fix: coverage --- src/course-outline/data/slice.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/course-outline/data/slice.ts b/src/course-outline/data/slice.ts index 412346bd26..aef6d508bc 100644 --- a/src/course-outline/data/slice.ts +++ b/src/course-outline/data/slice.ts @@ -171,6 +171,7 @@ const slice = createSlice({ }); }, addUnit: (state: CourseOutlineState, { payload }) => { + // istanbul ignore next state.sectionsList = state.sectionsList.map((section) => { section.childInfo.children = section.childInfo.children.map((subsection) => { if (subsection.id !== payload.parentLocator) { From e2db9d561bf6ed2f2d49f7e4fc6ae1f48ea1d6f0 Mon Sep 17 00:00:00 2001 From: Navin Karkera Date: Tue, 3 Feb 2026 12:41:35 +0530 Subject: [PATCH 39/60] fix: coverage --- src/course-outline/card-header/CardHeader.tsx | 2 + src/course-outline/hooks.jsx | 4 +- .../outline-sidebar/AddSidebar.test.tsx | 79 ++++++++++++++++++- .../outline-sidebar/AddSidebar.tsx | 5 +- .../outline-sidebar/OutlineAlignSidebar.tsx | 1 + .../section-card/SectionCard.tsx | 1 + .../subsection-card/SubsectionCard.tsx | 1 + src/course-outline/unit-card/UnitCard.tsx | 1 + 8 files changed, 89 insertions(+), 5 deletions(-) diff --git a/src/course-outline/card-header/CardHeader.tsx b/src/course-outline/card-header/CardHeader.tsx index 7f6acec7e8..cb17ada62d 100644 --- a/src/course-outline/card-header/CardHeader.tsx +++ b/src/course-outline/card-header/CardHeader.tsx @@ -159,6 +159,7 @@ const CardHeader = ({ ); useEscapeClick({ + // istanbul ignore next onEscape: () => { setTitleValue(title); closeForm(); @@ -211,6 +212,7 @@ const CardHeader = ({ aria-label={intl.formatMessage(messages.editFieldAriaLabel)} onBlur={handleEditSubmit} onKeyDown={(e) => { + // istanbul ignore if if (e.key === 'Enter') { handleEditSubmit(); } else if (e.key === ' ') { diff --git a/src/course-outline/hooks.jsx b/src/course-outline/hooks.jsx index 7ce72260cb..fb4716cd51 100644 --- a/src/course-outline/hooks.jsx +++ b/src/course-outline/hooks.jsx @@ -172,6 +172,7 @@ const useCourseOutline = ({ courseId }) => { await unlinkDownstream(currentUnlinkModalData.value.id, { onSuccess: () => { closeUnlinkModal(); + // istanbul ignore next // refresh child block data currentUnlinkModalData.value.childInfo.children.forEach((block) => { queryClient.invalidateQueries({ queryKey: courseOutlineQueryKeys.courseItemId(block.id) }); @@ -203,7 +204,8 @@ const useCourseOutline = ({ courseId }) => { dispatch(configureCourseUnitQuery(currentSelection?.currentId, currentSelection?.sectionId, ...arg)); break; default: - return; + // istanbul ignore next + throw new Error('Unsupported block type'); } handleConfigureModalClose(); }; diff --git a/src/course-outline/outline-sidebar/AddSidebar.test.tsx b/src/course-outline/outline-sidebar/AddSidebar.test.tsx index f2487815b8..3dbc44ffc5 100644 --- a/src/course-outline/outline-sidebar/AddSidebar.test.tsx +++ b/src/course-outline/outline-sidebar/AddSidebar.test.tsx @@ -22,6 +22,7 @@ import { XBlock } from '@src/data/types'; import { CourseAuthoringProvider } from '@src/CourseAuthoringContext'; import { snakeCaseKeys } from '@src/editors/utils'; import { getXBlockBaseApiUrl } from '@src/course-outline/data/api'; +import MockAdapter from 'axios-mock-adapter/types'; import { AddSidebar } from './AddSidebar'; mockContentSearchConfig.applyMock(); @@ -44,9 +45,10 @@ jest.mock('@src/CourseAuthoringContext', () => ({ }), })); +let outlineChildren = courseOutlineIndexMock.courseStructure.childInfo.children; jest.mock('react-redux', () => ({ ...jest.requireActual('react-redux'), - useSelector: jest.fn().mockReturnValue(courseOutlineIndexMock.courseStructure.childInfo.children), + useSelector: () => outlineChildren, })); jest.mock('@src/studio-home/hooks', () => ({ @@ -95,9 +97,9 @@ const searchResult = { }, ], }; -let axiosMock; +let axiosMock: MockAdapter; -describe('AddSidebar component', () => { +describe('AddSidebar', () => { beforeEach(() => { const mocks = initializeMocks(); axiosMock = mocks.axiosMock; @@ -116,6 +118,7 @@ describe('AddSidebar component', () => { newMockResult.results[0]?.hits.forEach((hit) => { hit._formatted = { ...hit }; }); return newMockResult; }); + outlineChildren = courseOutlineIndexMock.courseStructure.childInfo.children; }); it('renders the AddSidebar component without any errors', async () => { @@ -189,6 +192,76 @@ describe('AddSidebar component', () => { }))); }); + it('creates parent section if required', async () => { + const user = userEvent.setup(); + // the course is empty + outlineChildren = []; + const sectionId = 'block-v1:UNIX+UX1+2025_T3+type@chapter+block@chapter123'; + axiosMock.onPost(getXBlockBaseApiUrl()) + .reply(200, { locator: sectionId }); + renderComponent(); + + const subsection = await screen.findByRole('button', { name: 'Subsection' }); + await user.click(subsection); + // should add a section first + expect(axiosMock.history.post[0].data).toEqual(JSON.stringify(snakeCaseKeys({ + type: 'chapter', + category: 'chapter', + parentLocator: 'block-v1:UNIX+UX1+2025_T3+type@course+block@course', + displayName: 'Section', + }))); + // then subsection + expect(axiosMock.history.post[1].data).toEqual(JSON.stringify(snakeCaseKeys({ + type: 'sequential', + category: 'sequential', + parentLocator: sectionId, + displayName: 'Subsection', + }))); + }); + + it('creates parent section and subsection if required', async () => { + const user = userEvent.setup(); + // the course is empty + outlineChildren = []; + const sectionId = 'block-v1:UNIX+UX1+2025_T3+type@chapter+block@chapter123'; + const subsectionId = 'block-v1:UNIX+UX1+2025_T3+type@sequential+block@sequential234'; + const unitId = 'block-v1:UNIX+UX1+2025_T3+type@vertical+block@vertical2133'; + const sectionBody = snakeCaseKeys({ + type: 'chapter', + category: 'chapter', + parentLocator: 'block-v1:UNIX+UX1+2025_T3+type@course+block@course', + displayName: 'Section', + }); + const subsectionBody = snakeCaseKeys({ + type: 'sequential', + category: 'sequential', + parentLocator: sectionId, + displayName: 'Subsection', + }); + const unitBody = snakeCaseKeys({ + type: 'vertical', + category: 'vertical', + parentLocator: subsectionId, + displayName: 'Unit', + }); + axiosMock.onPost(getXBlockBaseApiUrl(), sectionBody) + .reply(200, { locator: sectionId }); + axiosMock.onPost(getXBlockBaseApiUrl(), subsectionBody) + .reply(200, { locator: subsectionId }); + axiosMock.onPost(getXBlockBaseApiUrl(), unitBody) + .reply(200, { locator: unitId }); + renderComponent(); + + const unit = await screen.findByRole('button', { name: 'Unit' }); + await user.click(unit); + // should add a section first + expect(axiosMock.history.post[0].data).toEqual(JSON.stringify(sectionBody)); + // then subsection + expect(axiosMock.history.post[1].data).toEqual(JSON.stringify(subsectionBody)); + // then unit + expect(axiosMock.history.post[2].data).toEqual(JSON.stringify(unitBody)); + }); + it('calls appropriate handlers on existing button click', async () => { const user = userEvent.setup(); const sectionList = courseOutlineIndexMock.courseStructure.childInfo.children; diff --git a/src/course-outline/outline-sidebar/AddSidebar.tsx b/src/course-outline/outline-sidebar/AddSidebar.tsx index dbbfcb3fdc..a23ac4d06d 100644 --- a/src/course-outline/outline-sidebar/AddSidebar.tsx +++ b/src/course-outline/outline-sidebar/AddSidebar.tsx @@ -332,7 +332,10 @@ const ShowLibraryContent = () => { { const { data: contentData } = useContentData(sidebarContentId); + // istanbul ignore next const handleBack = () => { clearSelection(); setCurrentSelection(undefined); diff --git a/src/course-outline/section-card/SectionCard.tsx b/src/course-outline/section-card/SectionCard.tsx index 9762fa12e6..b5a9b013ed 100644 --- a/src/course-outline/section-card/SectionCard.tsx +++ b/src/course-outline/section-card/SectionCard.tsx @@ -109,6 +109,7 @@ const SectionCard = ({ /** Temporary measure to keep the react-query state updated with redux state */ useEffect(() => { + // istanbul ignore if if (moment(initialData.editedOnRaw).isAfter(moment(section.editedOnRaw))) { queryClient.setQueryData(courseOutlineQueryKeys.courseItemId(initialData.id), initialData); } diff --git a/src/course-outline/subsection-card/SubsectionCard.tsx b/src/course-outline/subsection-card/SubsectionCard.tsx index e5d0b642f6..0574a37830 100644 --- a/src/course-outline/subsection-card/SubsectionCard.tsx +++ b/src/course-outline/subsection-card/SubsectionCard.tsx @@ -144,6 +144,7 @@ const SubsectionCard = ({ /** Temporary measure to keep the react-query state updated with redux state */ useEffect(() => { + // istanbul ignore if if (moment(initialData.editedOnRaw).isAfter(moment(subsection.editedOnRaw))) { queryClient.setQueryData(courseOutlineQueryKeys.courseItemId(initialData.id), initialData); } diff --git a/src/course-outline/unit-card/UnitCard.tsx b/src/course-outline/unit-card/UnitCard.tsx index 6098fe8f84..d9960aaa62 100644 --- a/src/course-outline/unit-card/UnitCard.tsx +++ b/src/course-outline/unit-card/UnitCard.tsx @@ -192,6 +192,7 @@ const UnitCard = ({ /** Temporary measure to keep the react-query state updated with redux state */ useEffect(() => { + // istanbul ignore if if (moment(initialData.editedOnRaw).isAfter(moment(unit.editedOnRaw))) { queryClient.setQueryData(courseOutlineQueryKeys.courseItemId(initialData.id), initialData); } From 1e942dc664fb02b03036c3339cb006b63e00e13c Mon Sep 17 00:00:00 2001 From: Navin Karkera Date: Tue, 3 Feb 2026 12:58:28 +0530 Subject: [PATCH 40/60] fix: lint issues --- src/course-outline/card-header/CardHeader.tsx | 4 ++-- src/course-outline/outline-sidebar/AddSidebar.tsx | 12 ++++++------ 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/course-outline/card-header/CardHeader.tsx b/src/course-outline/card-header/CardHeader.tsx index cb17ada62d..1c39deb712 100644 --- a/src/course-outline/card-header/CardHeader.tsx +++ b/src/course-outline/card-header/CardHeader.tsx @@ -168,9 +168,9 @@ const CardHeader = ({ }); const editMutation = useUpdateCourseBlockName(courseId); - const handleEditSubmit = useCallback(async () => { + const handleEditSubmit = useCallback(() => { if (title !== titleValue) { - await editMutation.mutateAsync({ + editMutation.mutate({ itemId: cardId, displayName: titleValue, }, { diff --git a/src/course-outline/outline-sidebar/AddSidebar.tsx b/src/course-outline/outline-sidebar/AddSidebar.tsx index a23ac4d06d..6c25670ec6 100644 --- a/src/course-outline/outline-sidebar/AddSidebar.tsx +++ b/src/course-outline/outline-sidebar/AddSidebar.tsx @@ -65,8 +65,8 @@ const AddContentButton = ({ name, blockType } : AddContentButtonProps) => { let sectionParentId = lastEditableSection?.id; let subsectionParentId = lastEditableSubsection?.data?.id; - const addSection = async (onSuccess?: (data: { locator: string; }) => void) => { - await handleAddSection.mutateAsync({ + const addSection = (onSuccess?: (data: { locator: string; }) => void) => { + handleAddSection.mutate({ type: ContainerType.Chapter, parentLocator: courseUsageKey, displayName: COURSE_BLOCK_NAMES.chapter.name, @@ -82,8 +82,8 @@ const AddContentButton = ({ name, blockType } : AddContentButtonProps) => { }); }; - const addSubsection = async (sectionId: string, onSuccess?: (data: { locator: string; }) => void) => { - await handleAddSubsection.mutateAsync({ + const addSubsection = (sectionId: string, onSuccess?: (data: { locator: string; }) => void) => { + handleAddSubsection.mutate({ type: ContainerType.Sequential, parentLocator: sectionId, displayName: COURSE_BLOCK_NAMES.sequential.name, @@ -99,8 +99,8 @@ const AddContentButton = ({ name, blockType } : AddContentButtonProps) => { }); }; - const addUnit = async (subsectionId: string, sectionId?: string, onSettled?: () => void) => { - await handleAddAndOpenUnit.mutateAsync({ + const addUnit = (subsectionId: string, sectionId?: string, onSettled?: () => void) => { + handleAddAndOpenUnit.mutate({ type: ContainerType.Vertical, parentLocator: subsectionId, displayName: COURSE_BLOCK_NAMES.vertical.name, From 46e694ea3ed0a13d93681e3433dc17eeca153659 Mon Sep 17 00:00:00 2001 From: Navin Karkera Date: Tue, 3 Feb 2026 14:39:33 +0530 Subject: [PATCH 41/60] fix: coverage --- src/CourseAuthoringContext.tsx | 2 ++ src/course-outline/card-header/CardHeader.tsx | 3 ++- src/course-outline/data/slice.ts | 1 + src/course-outline/section-card/SectionCard.tsx | 1 + 4 files changed, 6 insertions(+), 1 deletion(-) diff --git a/src/CourseAuthoringContext.tsx b/src/CourseAuthoringContext.tsx index e5bf3fb8cf..f14c7faf4f 100644 --- a/src/CourseAuthoringContext.tsx +++ b/src/CourseAuthoringContext.tsx @@ -146,7 +146,9 @@ export const CourseAuthoringProvider = ({ const addUnitToCourse = async (locator: string, parentLocator: string) => { try { const data = await getCourseItem(locator); + // istanbul ignore next data.shouldScroll = true; + // istanbul ignore next // Page should scroll to newly added subsection. dispatch(addUnit({ parentLocator, data })); } catch { diff --git a/src/course-outline/card-header/CardHeader.tsx b/src/course-outline/card-header/CardHeader.tsx index 1c39deb712..172482f5ad 100644 --- a/src/course-outline/card-header/CardHeader.tsx +++ b/src/course-outline/card-header/CardHeader.tsx @@ -159,9 +159,10 @@ const CardHeader = ({ ); useEscapeClick({ - // istanbul ignore next onEscape: () => { + // istanbul ignore next setTitleValue(title); + // istanbul ignore next closeForm(); }, dependency: [title], diff --git a/src/course-outline/data/slice.ts b/src/course-outline/data/slice.ts index aef6d508bc..ca0ac92fdd 100644 --- a/src/course-outline/data/slice.ts +++ b/src/course-outline/data/slice.ts @@ -170,6 +170,7 @@ const slice = createSlice({ return section; }); }, + // istanbul ignore next addUnit: (state: CourseOutlineState, { payload }) => { // istanbul ignore next state.sectionsList = state.sectionsList.map((section) => { diff --git a/src/course-outline/section-card/SectionCard.tsx b/src/course-outline/section-card/SectionCard.tsx index b5a9b013ed..ba24ca300e 100644 --- a/src/course-outline/section-card/SectionCard.tsx +++ b/src/course-outline/section-card/SectionCard.tsx @@ -272,6 +272,7 @@ const SectionCard = ({ status={sectionStatus} hasChanges={hasChanges} onClickMenuButton={handleClickMenuButton} + // istanbul ignore next onClickPublish={() => openPublishModal({ value: section, sectionId: section.id })} onClickConfigure={onOpenConfigureModal} onClickDelete={onOpenDeleteModal} From 581ec20dc6e7e4c86c5e8d8d7d4f8696bbb8222a Mon Sep 17 00:00:00 2001 From: Navin Karkera Date: Tue, 3 Feb 2026 14:52:14 +0530 Subject: [PATCH 42/60] fix: flaky test --- src/course-unit/CourseUnit.test.jsx | 21 ++++++++------------- 1 file changed, 8 insertions(+), 13 deletions(-) diff --git a/src/course-unit/CourseUnit.test.jsx b/src/course-unit/CourseUnit.test.jsx index 5e1edd70f8..8dd56ea400 100644 --- a/src/course-unit/CourseUnit.test.jsx +++ b/src/course-unit/CourseUnit.test.jsx @@ -582,12 +582,11 @@ describe('', () => { } = courseSectionVerticalMock; const viewLiveButton = await screen.findByRole('button', { name: headerNavigationsMessages.viewLiveButton.defaultMessage }); - await user.click(viewLiveButton); expect(window.open).toHaveBeenCalled(); expect(window.open).toHaveBeenCalledWith(publishedPreviewLink, '_blank'); - const previewButton = screen.getByRole('button', { name: headerNavigationsMessages.previewButton.defaultMessage }); + const previewButton = await screen.findByRole('button', { name: headerNavigationsMessages.previewButton.defaultMessage }); await user.click(previewButton); expect(window.open).toHaveBeenCalled(); expect(window.open).toHaveBeenCalledWith(draftPreviewLink, '_blank'); @@ -664,16 +663,14 @@ describe('', () => { axiosMock .onPost(postXBlockBaseApiUrl({ type: 'video', category: 'video', parentLocator: blockId })) .reply(500, {}); - const { getByRole } = render(); - - await waitFor(async () => { - const videoButton = getByRole('button', { - name: new RegExp(`${addComponentMessages.buttonText.defaultMessage} Video`, 'i'), - }); + render(); - await user.click(videoButton); - expect(mockedUsedNavigate).not.toHaveBeenCalledWith(`/course/${courseKey}/editor/video/${locator}`); + const videoButton = await screen.findByRole('button', { + name: new RegExp(`${addComponentMessages.buttonText.defaultMessage} Video`, 'i'), }); + + await user.click(videoButton); + expect(mockedUsedNavigate).not.toHaveBeenCalledWith(`/course/${courseKey}/editor/video/${locator}`); }); it('handle creating Problem xblock and showing editor modal', async () => { @@ -683,9 +680,7 @@ describe('', () => { .reply(200, courseCreateXblockMock); render(); - await waitFor(async () => { - await user.click(screen.getByRole('button', { name: legacySidebarMessages.actionButtonPublishTitle.defaultMessage })); - }); + await user.click(await screen.findByRole('button', { name: legacySidebarMessages.actionButtonPublishTitle.defaultMessage })); axiosMock .onPost(getXBlockBaseApiUrl(blockId), { From 54dd13472236ae17fec43392ade3f0da114edb83 Mon Sep 17 00:00:00 2001 From: Navin Karkera Date: Tue, 3 Feb 2026 15:29:48 +0530 Subject: [PATCH 43/60] fix: coverage --- src/course-outline/card-header/CardHeader.tsx | 7 ++----- src/course-outline/section-card/SectionCard.tsx | 6 ++++-- src/course-outline/subsection-card/SubsectionCard.tsx | 5 ++++- src/course-outline/unit-card/UnitCard.tsx | 2 +- 4 files changed, 11 insertions(+), 9 deletions(-) diff --git a/src/course-outline/card-header/CardHeader.tsx b/src/course-outline/card-header/CardHeader.tsx index 172482f5ad..90b3979999 100644 --- a/src/course-outline/card-header/CardHeader.tsx +++ b/src/course-outline/card-header/CardHeader.tsx @@ -159,10 +159,8 @@ const CardHeader = ({ ); useEscapeClick({ - onEscape: () => { - // istanbul ignore next + onEscape: /* istanbul ignore next */ () => { setTitleValue(title); - // istanbul ignore next closeForm(); }, dependency: [title], @@ -212,8 +210,7 @@ const CardHeader = ({ onChange={(e) => setTitleValue(e.target.value)} aria-label={intl.formatMessage(messages.editFieldAriaLabel)} onBlur={handleEditSubmit} - onKeyDown={(e) => { - // istanbul ignore if + onKeyDown={/* istanbul ignore next */ (e) => { if (e.key === 'Enter') { handleEditSubmit(); } else if (e.key === ' ') { diff --git a/src/course-outline/section-card/SectionCard.tsx b/src/course-outline/section-card/SectionCard.tsx index ba24ca300e..9673ec4f98 100644 --- a/src/course-outline/section-card/SectionCard.tsx +++ b/src/course-outline/section-card/SectionCard.tsx @@ -272,8 +272,10 @@ const SectionCard = ({ status={sectionStatus} hasChanges={hasChanges} onClickMenuButton={handleClickMenuButton} - // istanbul ignore next - onClickPublish={() => openPublishModal({ value: section, sectionId: section.id })} + onClickPublish={/* istanbul ignore next */ () => openPublishModal({ + value: section, + sectionId: section.id, + })} onClickConfigure={onOpenConfigureModal} onClickDelete={onOpenDeleteModal} onClickUnlink={() => openUnlinkModal({ value: section, sectionId: section.id })} diff --git a/src/course-outline/subsection-card/SubsectionCard.tsx b/src/course-outline/subsection-card/SubsectionCard.tsx index 0574a37830..3df7f949e1 100644 --- a/src/course-outline/subsection-card/SubsectionCard.tsx +++ b/src/course-outline/subsection-card/SubsectionCard.tsx @@ -280,7 +280,10 @@ const SubsectionCard = ({ onClickMenuButton={handleClickMenuButton} onClickPublish={() => openPublishModal({ value: subsection, sectionId: section.id })} onClickDelete={onOpenDeleteModal} - onClickUnlink={() => openUnlinkModal({ value: subsection, sectionId: section.id })} + onClickUnlink={/* istanbul ignore next */ () => openUnlinkModal({ + value: subsection, + sectionId: section.id, + })} onClickMoveUp={handleSubsectionMoveUp} onClickMoveDown={handleSubsectionMoveDown} onClickConfigure={onOpenConfigureModal} diff --git a/src/course-outline/unit-card/UnitCard.tsx b/src/course-outline/unit-card/UnitCard.tsx index d9960aaa62..1ba5331f72 100644 --- a/src/course-outline/unit-card/UnitCard.tsx +++ b/src/course-outline/unit-card/UnitCard.tsx @@ -259,7 +259,7 @@ const UnitCard = ({ })} onClickConfigure={onOpenConfigureModal} onClickDelete={onOpenDeleteModal} - onClickUnlink={() => openUnlinkModal({ + onClickUnlink={/* istanbul ignore next */ () => openUnlinkModal({ value: unit, sectionId: section.id, subsectionId: subsection.id, From bc81321018df0eee5f199926fe66e546a86c183d Mon Sep 17 00:00:00 2001 From: Navin Karkera Date: Wed, 4 Feb 2026 12:20:12 +0530 Subject: [PATCH 44/60] refactor: close title edit form only after update is complete --- src/course-outline/card-header/CardHeader.tsx | 12 ++++++------ src/course-outline/data/api.ts | 2 -- src/course-outline/data/apiHooks.ts | 8 ++++---- 3 files changed, 10 insertions(+), 12 deletions(-) diff --git a/src/course-outline/card-header/CardHeader.tsx b/src/course-outline/card-header/CardHeader.tsx index 90b3979999..90565e1154 100644 --- a/src/course-outline/card-header/CardHeader.tsx +++ b/src/course-outline/card-header/CardHeader.tsx @@ -173,18 +173,19 @@ const CardHeader = ({ itemId: cardId, displayName: titleValue, }, { - onSuccess: () => { - closeForm(); - queryClient.invalidateQueries({ + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: courseOutlineQueryKeys.courseItemId(currentSelection?.sectionId), }); - queryClient.invalidateQueries({ + await queryClient.invalidateQueries({ queryKey: courseOutlineQueryKeys.courseItemId(currentSelection?.subsectionId), }); }, + onSettled: () => closeForm(), }); + } else { + closeForm(); } - closeForm(); }, [title, titleValue, cardId, editMutation]); return ( @@ -233,7 +234,6 @@ const CardHeader = ({ tooltipContent={
{intl.formatMessage(messages.altButtonRename)}
} iconAs={EditIcon} onClick={onEditClick} - // @ts-ignore disabled={editMutation.isPending} />
diff --git a/src/course-outline/data/api.ts b/src/course-outline/data/api.ts index 6f57b9aaf4..8dc5489ebf 100644 --- a/src/course-outline/data/api.ts +++ b/src/course-outline/data/api.ts @@ -202,8 +202,6 @@ export async function updateCourseSectionHighlights( /** * Publish course item - * @param {string} itemId - * @returns {Promise} */ export async function publishCourseItem(itemId: string): Promise { const { data } = await getAuthenticatedHttpClient() diff --git a/src/course-outline/data/apiHooks.ts b/src/course-outline/data/apiHooks.ts index a2cf7b37c6..aa5767d427 100644 --- a/src/course-outline/data/apiHooks.ts +++ b/src/course-outline/data/apiHooks.ts @@ -73,10 +73,10 @@ export const useUpdateCourseBlockName = (courseId: string) => { const queryClient = useQueryClient(); return useMutation({ mutationFn: editItemDisplayName, - onSettled: async (_data, _err, variables) => { - queryClient.invalidateQueries({ queryKey: containerComparisonQueryKeys.course(courseId) }); - queryClient.invalidateQueries({ queryKey: courseOutlineQueryKeys.courseDetails(courseId) }); - queryClient.invalidateQueries({ queryKey: courseOutlineQueryKeys.courseItemId(variables.itemId) }); + onSuccess: async (_data, variables) => { + await queryClient.invalidateQueries({ queryKey: containerComparisonQueryKeys.course(courseId) }); + await queryClient.invalidateQueries({ queryKey: courseOutlineQueryKeys.courseDetails(courseId) }); + await queryClient.invalidateQueries({ queryKey: courseOutlineQueryKeys.courseItemId(variables.itemId) }); }, }); }; From 4f8c4aac31b6f8a042502fac715da220734ed8b0 Mon Sep 17 00:00:00 2001 From: Navin Karkera Date: Wed, 4 Feb 2026 15:33:17 +0530 Subject: [PATCH 45/60] chore: apply review suggestions --- src/CourseAuthoringContext.tsx | 7 +------ src/course-outline/card-header/CardHeader.tsx | 3 ++- .../outline-sidebar/AddSidebar.tsx | 19 +++---------------- .../outline-sidebar/LibraryReferenceCard.tsx | 2 +- .../outline-sidebar/OutlineAlignSidebar.tsx | 7 +------ .../outline-sidebar/OutlineSidebarContext.tsx | 7 +++---- 6 files changed, 11 insertions(+), 34 deletions(-) diff --git a/src/CourseAuthoringContext.tsx b/src/CourseAuthoringContext.tsx index f14c7faf4f..bdf3a68b3f 100644 --- a/src/CourseAuthoringContext.tsx +++ b/src/CourseAuthoringContext.tsx @@ -3,7 +3,7 @@ import { createContext, useContext, useMemo, useState, } from 'react'; import { getAuthenticatedUser } from '@edx/frontend-platform/auth'; -import { courseOutlineQueryKeys, useCreateCourseBlock } from '@src/course-outline/data/apiHooks'; +import { useCreateCourseBlock } from '@src/course-outline/data/apiHooks'; import { getCourseItem } from '@src/course-outline/data/api'; import { useDispatch, useSelector } from 'react-redux'; import { @@ -13,7 +13,6 @@ import { useNavigate } from 'react-router'; import { getOutlineIndexData } from '@src/course-outline/data/selectors'; import { useToggleWithValue } from '@src/hooks'; import { SelectionState, XBlock } from '@src/data/types'; -import { useQueryClient } from '@tanstack/react-query'; import { CourseDetailsData } from './data/api'; import { useCourseDetails, useWaffleFlags } from './data/apiHooks'; import { RequestStatus, RequestStatusType } from './data/constants'; @@ -94,7 +93,6 @@ export const CourseAuthoringProvider = ({ * It is mostly used in modals which should be soon be replaced with its equivalent in sidebar. */ const [currentSelection, setCurrentSelection] = useState(); - const queryClient = useQueryClient(); const getUnitUrl = (locator: string) => { if (getConfig().ENABLE_UNIT_PAGE === 'true' && waffleFlags.useNewUnitPage) { @@ -108,9 +106,6 @@ export const CourseAuthoringProvider = ({ * Open the unit page for a given locator. */ const openUnitPage = async (locator: string) => { - queryClient.invalidateQueries({ - queryKey: courseOutlineQueryKeys.courseItemId(currentSelection?.sectionId), - }); const url = getUnitUrl(locator); if (getConfig().ENABLE_UNIT_PAGE === 'true' && waffleFlags.useNewUnitPage) { // instanbul ignore next diff --git a/src/course-outline/card-header/CardHeader.tsx b/src/course-outline/card-header/CardHeader.tsx index 90565e1154..42ce79253f 100644 --- a/src/course-outline/card-header/CardHeader.tsx +++ b/src/course-outline/card-header/CardHeader.tsx @@ -111,7 +111,8 @@ const CardHeader = ({ const openManageTagsDrawer = useCallback(() => { const showNewSidebar = getConfig().ENABLE_COURSE_OUTLINE_NEW_DESIGN?.toString().toLowerCase() === 'true'; - if (showNewSidebar) { + const showAlignSidebar = getConfig().ENABLE_TAGGING_TAXONOMY_PAGES === 'true'; + if (showNewSidebar && showAlignSidebar) { setCurrentPageKey('align'); onClickMenuButton(); } else { diff --git a/src/course-outline/outline-sidebar/AddSidebar.tsx b/src/course-outline/outline-sidebar/AddSidebar.tsx index 6c25670ec6..58595146bd 100644 --- a/src/course-outline/outline-sidebar/AddSidebar.tsx +++ b/src/course-outline/outline-sidebar/AddSidebar.tsx @@ -99,19 +99,14 @@ const AddContentButton = ({ name, blockType } : AddContentButtonProps) => { }); }; - const addUnit = (subsectionId: string, sectionId?: string, onSettled?: () => void) => { + const addUnit = (subsectionId: string, sectionId?: string) => { handleAddAndOpenUnit.mutate({ type: ContainerType.Vertical, parentLocator: subsectionId, displayName: COURSE_BLOCK_NAMES.vertical.name, }, { onSettled: () => { - // istanbul ignore next - if (onSettled) { - onSettled(); - } else { - queryClient.invalidateQueries({ queryKey: courseOutlineQueryKeys.courseItemId(sectionId) }); - } + queryClient.invalidateQueries({ queryKey: courseOutlineQueryKeys.courseItemId(sectionId) }); }, }); }; @@ -164,15 +159,7 @@ const AddContentButton = ({ name, blockType } : AddContentButtonProps) => { lastEditableSubsection, ]); - const disabled = useMemo(() => ( - handleAddSection.isPending - || handleAddSubsection.isPending - || handleAddAndOpenUnit.isPending - ), [ - handleAddSection, - handleAddSubsection, - handleAddAndOpenUnit, - ]); + const disabled = handleAddSection.isPending || handleAddSubsection.isPending || handleAddAndOpenUnit.isPending; return (