From 6f9c5b33cc91ed8a0e0ca0a866a3139dd1992429 Mon Sep 17 00:00:00 2001 From: Navin Karkera Date: Wed, 3 Dec 2025 19:24:09 +0530 Subject: [PATCH 01/22] refactor: convert subheader component to tsx --- src/course-libraries/CourseLibraries.tsx | 4 +- .../{SubHeader.jsx => SubHeader.tsx} | 57 ++++++------------- 2 files changed, 20 insertions(+), 41 deletions(-) rename src/generic/sub-header/{SubHeader.jsx => SubHeader.tsx} (59%) diff --git a/src/course-libraries/CourseLibraries.tsx b/src/course-libraries/CourseLibraries.tsx index e939ec5c7c..dcc8a91660 100644 --- a/src/course-libraries/CourseLibraries.tsx +++ b/src/course-libraries/CourseLibraries.tsx @@ -198,7 +198,7 @@ export const CourseLibraries = () => { 0 && tabKey === CourseLibraryTabs.all && ( + headerActions={(!showReviewAlert && outOfSyncCount > 0 && tabKey === CourseLibraryTabs.all) ? ( - )} + ): null} hideBorder />
diff --git a/src/generic/sub-header/SubHeader.jsx b/src/generic/sub-header/SubHeader.tsx similarity index 59% rename from src/generic/sub-header/SubHeader.jsx rename to src/generic/sub-header/SubHeader.tsx index 1cc1a665fb..d79a420c8f 100644 --- a/src/generic/sub-header/SubHeader.jsx +++ b/src/generic/sub-header/SubHeader.tsx @@ -1,19 +1,31 @@ -import React from 'react'; -import PropTypes from 'prop-types'; import { ActionRow } from '@openedx/paragon'; +import { ReactElement, } from 'react'; + +interface SubHeaderProps { + title: ReactElement | string | null; + subtitle?: string; + breadcrumbs?: ReactElement | ReactElement[] | string | null; + contentTitle?: string; + description?: string; + instruction?: ReactElement | string, + headerActions?: ReactElement | ReactElement[] | null; + titleActions?: ReactElement | ReactElement[] | null; + hideBorder?: boolean; + withSubHeaderContent?: boolean; +}; const SubHeader = ({ title, - subtitle, + subtitle = '', breadcrumbs, contentTitle, - description, + description = '', instruction, headerActions, titleActions, - hideBorder, + hideBorder = false, withSubHeaderContent, -}) => ( +}: SubHeaderProps) => (
{breadcrumbs && (
{breadcrumbs}
@@ -46,37 +58,4 @@ const SubHeader = ({
); -SubHeader.defaultProps = { - instruction: '', - description: '', - subtitle: '', - breadcrumbs: '', - contentTitle: '', - headerActions: null, - titleActions: null, - hideBorder: false, - withSubHeaderContent: true, -}; - -SubHeader.propTypes = { - title: PropTypes.oneOfType([ - PropTypes.node, - PropTypes.string, - ]).isRequired, - subtitle: PropTypes.string, - breadcrumbs: PropTypes.oneOfType([ - PropTypes.node, - PropTypes.string, - ]), - contentTitle: PropTypes.string, - description: PropTypes.string, - instruction: PropTypes.oneOfType([ - PropTypes.element, - PropTypes.string, - ]), - headerActions: PropTypes.node, - titleActions: PropTypes.node, - hideBorder: PropTypes.bool, - withSubHeaderContent: PropTypes.bool, -}; export default SubHeader; From 9d435bbd4da37b5924ca14663a9cd182342898a0 Mon Sep 17 00:00:00 2001 From: Navin Karkera Date: Thu, 4 Dec 2025 11:37:59 +0530 Subject: [PATCH 02/22] refactor: convert course outline header plugin slot and related files to tsx --- src/course-libraries/CourseLibraries.tsx | 2 +- .../HeaderNavigations.test.jsx | 138 ----------------- .../HeaderNavigations.test.tsx | 142 ++++++++++++++++++ ...rNavigations.jsx => HeaderNavigations.tsx} | 62 +++----- src/data/types.ts | 12 ++ src/generic/sub-header/SubHeader.tsx | 4 +- .../CourseOutlineHeaderActionsSlot/index.jsx | 86 ----------- .../CourseOutlineHeaderActionsSlot/index.tsx | 48 ++++++ tsconfig.json | 3 +- 9 files changed, 225 insertions(+), 272 deletions(-) delete mode 100644 src/course-outline/header-navigations/HeaderNavigations.test.jsx create mode 100644 src/course-outline/header-navigations/HeaderNavigations.test.tsx rename src/course-outline/header-navigations/{HeaderNavigations.jsx => HeaderNavigations.tsx} (67%) delete mode 100644 src/plugin-slots/CourseOutlineHeaderActionsSlot/index.jsx create mode 100644 src/plugin-slots/CourseOutlineHeaderActionsSlot/index.tsx diff --git a/src/course-libraries/CourseLibraries.tsx b/src/course-libraries/CourseLibraries.tsx index dcc8a91660..7ab6a1bdf0 100644 --- a/src/course-libraries/CourseLibraries.tsx +++ b/src/course-libraries/CourseLibraries.tsx @@ -206,7 +206,7 @@ export const CourseLibraries = () => { > {intl.formatMessage(messages.reviewUpdatesBtn)} - ): null} + ) : null} hideBorder />
diff --git a/src/course-outline/header-navigations/HeaderNavigations.test.jsx b/src/course-outline/header-navigations/HeaderNavigations.test.jsx deleted file mode 100644 index 46d9affa65..0000000000 --- a/src/course-outline/header-navigations/HeaderNavigations.test.jsx +++ /dev/null @@ -1,138 +0,0 @@ -import React from 'react'; -import { render, fireEvent, waitFor } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import { IntlProvider } from '@edx/frontend-platform/i18n'; - -import HeaderNavigations from './HeaderNavigations'; -import messages from './messages'; - -const handleNewSectionMock = jest.fn(); -const handleReIndexMock = jest.fn(); -const handleExpandAllMock = jest.fn(); - -const headerNavigationsActions = { - handleNewSection: handleNewSectionMock, - handleReIndex: handleReIndexMock, - handleExpandAll: handleExpandAllMock, - lmsLink: '', -}; - -const courseActions = { - draggable: true, - childAddable: true, - deletable: true, - duplicable: true, -}; - -const renderComponent = (props) => render( - - - , -); - -describe('', () => { - it('render HeaderNavigations component correctly', () => { - const { getByRole } = renderComponent(); - - expect(getByRole('button', { name: messages.newSectionButton.defaultMessage })).toBeInTheDocument(); - expect(getByRole('button', { name: messages.reindexButton.defaultMessage })).toBeInTheDocument(); - expect(getByRole('button', { name: messages.expandAllButton.defaultMessage })).toBeInTheDocument(); - expect(getByRole('button', { name: messages.viewLiveButton.defaultMessage })).toBeInTheDocument(); - }); - - it('render HeaderNavigations component with isReIndexShow is false correctly', () => { - const { getByRole, queryByRole } = renderComponent({ isReIndexShow: false }); - - expect(getByRole('button', { name: messages.newSectionButton.defaultMessage })).toBeInTheDocument(); - expect(queryByRole('button', { name: messages.reindexButton.defaultMessage })).not.toBeInTheDocument(); - expect(getByRole('button', { name: messages.expandAllButton.defaultMessage })).toBeInTheDocument(); - expect(getByRole('button', { name: messages.viewLiveButton.defaultMessage })).toBeInTheDocument(); - }); - - it('calls the correct handlers when clicking buttons', () => { - const { getByRole } = renderComponent(); - - const newSectionButton = getByRole('button', { name: messages.newSectionButton.defaultMessage }); - fireEvent.click(newSectionButton); - expect(handleNewSectionMock).toHaveBeenCalledTimes(1); - - const reIndexButton = getByRole('button', { name: messages.reindexButton.defaultMessage }); - fireEvent.click(reIndexButton); - expect(handleReIndexMock).toHaveBeenCalledTimes(1); - - const expandAllButton = getByRole('button', { name: messages.expandAllButton.defaultMessage }); - fireEvent.click(expandAllButton); - expect(handleExpandAllMock).toHaveBeenCalledTimes(1); - }); - - it('render collapse button correctly', () => { - const { getByRole } = renderComponent({ - isSectionsExpanded: true, - }); - - expect(getByRole('button', { name: messages.collapseAllButton.defaultMessage })).toBeInTheDocument(); - }); - - it('render expand button correctly', () => { - const { getByRole } = renderComponent({ - isSectionsExpanded: false, - }); - - expect(getByRole('button', { name: messages.expandAllButton.defaultMessage })).toBeInTheDocument(); - }); - - it('render collapse button correctly', () => { - const { getByRole } = renderComponent({ - isSectionsExpanded: true, - }); - - expect(getByRole('button', { name: messages.collapseAllButton.defaultMessage })).toBeInTheDocument(); - }); - - it('render expand button correctly', () => { - const { getByRole } = renderComponent({ - isSectionsExpanded: false, - }); - - expect(getByRole('button', { name: messages.expandAllButton.defaultMessage })).toBeInTheDocument(); - }); - - it('render reindex button tooltip correctly', async () => { - const user = userEvent.setup(); - const { getByText, getByRole } = renderComponent({ - isDisabledReindexButton: false, - }); - await user.hover(getByRole('button', { name: messages.reindexButton.defaultMessage })); - await waitFor(() => { - expect(getByText(messages.reindexButtonTooltip.defaultMessage)).toBeInTheDocument(); - }); - }); - - it('not render reindex button tooltip when button is disabled correctly', async () => { - const user = userEvent.setup(); - const { queryByText, getByRole } = renderComponent({ - isDisabledReindexButton: true, - }); - await user.pointer(getByRole('button', { name: messages.reindexButton.defaultMessage })); - await waitFor(() => { - expect(queryByText(messages.reindexButtonTooltip.defaultMessage)).not.toBeInTheDocument(); - }); - }); - - it('disables new section button if course outline fetch fails', () => { - const { getByRole } = renderComponent({ - errors: { outlineIndexApi: { data: 'some error', type: 'serverError' } }, - }); - - expect(getByRole('button', { name: messages.newSectionButton.defaultMessage })).toBeInTheDocument(); - expect(getByRole('button', { name: messages.newSectionButton.defaultMessage })).toBeDisabled(); - }); -}); diff --git a/src/course-outline/header-navigations/HeaderNavigations.test.tsx b/src/course-outline/header-navigations/HeaderNavigations.test.tsx new file mode 100644 index 0000000000..fc3cbed3ec --- /dev/null +++ b/src/course-outline/header-navigations/HeaderNavigations.test.tsx @@ -0,0 +1,142 @@ +import userEvent from '@testing-library/user-event'; + +import { + fireEvent, initializeMocks, render, screen, waitFor, +} from '@src/testUtils'; +import HeaderNavigations, { HeaderNavigationsProps } from './HeaderNavigations'; +import messages from './messages'; + +const handleNewSectionMock = jest.fn(); +const handleReIndexMock = jest.fn(); +const handleExpandAllMock = jest.fn(); + +const headerNavigationsActions = { + handleNewSection: handleNewSectionMock, + handleReIndex: handleReIndexMock, + handleExpandAll: handleExpandAllMock, + lmsLink: '', +}; + +const courseActions = { + draggable: true, + childAddable: true, + deletable: true, + duplicable: true, +}; + +const renderComponent = (props?: Partial) => render( + , +); + +describe('', () => { + beforeEach(() => { + initializeMocks(); + }); + + it('render HeaderNavigations component correctly', async () => { + renderComponent(); + + expect(await screen.findByRole('button', { name: messages.newSectionButton.defaultMessage })).toBeInTheDocument(); + expect(await screen.findByRole('button', { name: messages.reindexButton.defaultMessage })).toBeInTheDocument(); + expect(await screen.findByRole('button', { name: messages.expandAllButton.defaultMessage })).toBeInTheDocument(); + expect(await screen.findByRole('button', { name: messages.viewLiveButton.defaultMessage })).toBeInTheDocument(); + }); + + it('render HeaderNavigations component with isReIndexShow is false correctly', async () => { + renderComponent({ isReIndexShow: false }); + + expect(await screen.findByRole('button', { name: messages.newSectionButton.defaultMessage })).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: messages.reindexButton.defaultMessage })).not.toBeInTheDocument(); + expect(await screen.findByRole('button', { name: messages.expandAllButton.defaultMessage })).toBeInTheDocument(); + expect(await screen.findByRole('button', { name: messages.viewLiveButton.defaultMessage })).toBeInTheDocument(); + }); + + it('calls the correct handlers when clicking buttons', async () => { + renderComponent(); + + const newSectionButton = await screen.findByRole('button', { name: messages.newSectionButton.defaultMessage }); + fireEvent.click(newSectionButton); + expect(handleNewSectionMock).toHaveBeenCalledTimes(1); + + const reIndexButton = await screen.findByRole('button', { name: messages.reindexButton.defaultMessage }); + fireEvent.click(reIndexButton); + expect(handleReIndexMock).toHaveBeenCalledTimes(1); + + const expandAllButton = await screen.findByRole('button', { name: messages.expandAllButton.defaultMessage }); + fireEvent.click(expandAllButton); + expect(handleExpandAllMock).toHaveBeenCalledTimes(1); + }); + + it('render collapse button correctly', async () => { + renderComponent({ + isSectionsExpanded: true, + }); + + expect(await screen.findByRole('button', { name: messages.collapseAllButton.defaultMessage })).toBeInTheDocument(); + }); + + it('render expand button correctly', async () => { + renderComponent({ + isSectionsExpanded: false, + }); + + expect(await screen.findByRole('button', { name: messages.expandAllButton.defaultMessage })).toBeInTheDocument(); + }); + + it('render collapse button correctly', async () => { + renderComponent({ + isSectionsExpanded: true, + }); + + expect(await screen.findByRole('button', { name: messages.collapseAllButton.defaultMessage })).toBeInTheDocument(); + }); + + it('render expand button correctly', async () => { + renderComponent({ + isSectionsExpanded: false, + }); + + expect(await screen.findByRole('button', { name: messages.expandAllButton.defaultMessage })).toBeInTheDocument(); + }); + + it('render reindex button tooltip correctly', async () => { + const user = userEvent.setup(); + renderComponent({ + isDisabledReindexButton: false, + }); + await user.hover(await screen.findByRole('button', { name: messages.reindexButton.defaultMessage })); + await waitFor(async () => { + expect(await screen.findByText(messages.reindexButtonTooltip.defaultMessage)).toBeInTheDocument(); + }); + }); + + it('not render reindex button tooltip when button is disabled correctly', async () => { + const user = userEvent.setup(); + renderComponent({ + isDisabledReindexButton: true, + }); + await user.pointer({ + target: (await screen.findByRole('button', { name: messages.reindexButton.defaultMessage })), + }); + await waitFor(() => { + expect(screen.queryByText(messages.reindexButtonTooltip.defaultMessage)).not.toBeInTheDocument(); + }); + }); + + it('disables new section button if course outline fetch fails', async () => { + renderComponent({ + errors: { outlineIndexApi: { data: 'some error', type: 'serverError' } }, + }); + + expect(await screen.findByRole('button', { name: messages.newSectionButton.defaultMessage })).toBeInTheDocument(); + expect(await screen.findByRole('button', { name: messages.newSectionButton.defaultMessage })).toBeDisabled(); + }); +}); diff --git a/src/course-outline/header-navigations/HeaderNavigations.jsx b/src/course-outline/header-navigations/HeaderNavigations.tsx similarity index 67% rename from src/course-outline/header-navigations/HeaderNavigations.jsx rename to src/course-outline/header-navigations/HeaderNavigations.tsx index 9f7ee50e77..90c98038d7 100644 --- a/src/course-outline/header-navigations/HeaderNavigations.jsx +++ b/src/course-outline/header-navigations/HeaderNavigations.tsx @@ -1,5 +1,4 @@ import React from 'react'; -import PropTypes from 'prop-types'; import { useIntl } from '@edx/frontend-platform/i18n'; import { Button, OverlayTrigger, Tooltip } from '@openedx/paragon'; import { @@ -8,8 +7,24 @@ import { ArrowDropUp as ArrowUpIcon, } from '@openedx/paragon/icons'; +import { OutlinePageErrors, XBlockActions } from '@src/data/types'; import messages from './messages'; +export interface HeaderNavigationsProps { + isReIndexShow: boolean, + isSectionsExpanded: boolean, + isDisabledReindexButton: boolean, + headerNavigationsActions: { + handleNewSection: () => void, + handleReIndex: () => void, + handleExpandAll: () => void, + lmsLink: string, + }, + hasSections: boolean, + courseActions: XBlockActions, + errors?: OutlinePageErrors, +} + const HeaderNavigations = ({ headerNavigationsActions, isReIndexShow, @@ -18,7 +33,7 @@ const HeaderNavigations = ({ hasSections, courseActions, errors, -}) => { +}: HeaderNavigationsProps) => { const intl = useIntl(); const { handleNewSection, handleReIndex, handleExpandAll, lmsLink, @@ -38,7 +53,7 @@ const HeaderNavigations = ({ @@ -96,45 +111,4 @@ const HeaderNavigations = ({ ); }; -HeaderNavigations.defaultProps = { - errors: {}, -}; - -HeaderNavigations.propTypes = { - isReIndexShow: PropTypes.bool.isRequired, - isSectionsExpanded: PropTypes.bool.isRequired, - isDisabledReindexButton: PropTypes.bool.isRequired, - headerNavigationsActions: PropTypes.shape({ - handleNewSection: PropTypes.func.isRequired, - handleReIndex: PropTypes.func.isRequired, - handleExpandAll: PropTypes.func.isRequired, - lmsLink: PropTypes.string.isRequired, - }).isRequired, - hasSections: PropTypes.bool.isRequired, - courseActions: PropTypes.shape({ - deletable: PropTypes.bool.isRequired, - draggable: PropTypes.bool.isRequired, - childAddable: PropTypes.bool.isRequired, - duplicable: PropTypes.bool.isRequired, - }).isRequired, - errors: PropTypes.shape({ - outlineIndexApi: PropTypes.shape({ - data: PropTypes.string, - type: PropTypes.string.isRequired, - }), - reindexApi: PropTypes.shape({ - data: PropTypes.string, - type: PropTypes.string.isRequired, - }), - sectionLoadingApi: PropTypes.shape({ - data: PropTypes.string, - type: PropTypes.string.isRequired, - }), - courseLaunchApi: PropTypes.shape({ - data: PropTypes.string, - type: PropTypes.string.isRequired, - }), - }), -}; - export default HeaderNavigations; diff --git a/src/data/types.ts b/src/data/types.ts index 304dc61e73..95fe264716 100644 --- a/src/data/types.ts +++ b/src/data/types.ts @@ -123,3 +123,15 @@ export interface XBlock { discussionEnabled?: boolean; upstreamInfo?: UpstreamInfo; } + +interface OutlineError { + data?: string; + type: string; +} + +export interface OutlinePageErrors { + outlineIndexApi?: OutlineError | null, + reindexApi?: OutlineError | null, + sectionLoadingApi?: OutlineError | null, + courseLaunchApi?: OutlineError | null, +} diff --git a/src/generic/sub-header/SubHeader.tsx b/src/generic/sub-header/SubHeader.tsx index d79a420c8f..eed36113e0 100644 --- a/src/generic/sub-header/SubHeader.tsx +++ b/src/generic/sub-header/SubHeader.tsx @@ -1,5 +1,5 @@ import { ActionRow } from '@openedx/paragon'; -import { ReactElement, } from 'react'; +import { ReactElement } from 'react'; interface SubHeaderProps { title: ReactElement | string | null; @@ -12,7 +12,7 @@ interface SubHeaderProps { titleActions?: ReactElement | ReactElement[] | null; hideBorder?: boolean; withSubHeaderContent?: boolean; -}; +} const SubHeader = ({ title, diff --git a/src/plugin-slots/CourseOutlineHeaderActionsSlot/index.jsx b/src/plugin-slots/CourseOutlineHeaderActionsSlot/index.jsx deleted file mode 100644 index 82093cd2e3..0000000000 --- a/src/plugin-slots/CourseOutlineHeaderActionsSlot/index.jsx +++ /dev/null @@ -1,86 +0,0 @@ -import React from 'react'; -import PropTypes from 'prop-types'; -import { PluginSlot } from '@openedx/frontend-plugin-framework'; - -import HeaderNavigations from 'CourseAuthoring/course-outline/header-navigations/HeaderNavigations'; - -const CourseOutlineHeaderActionsSlot = ({ - headerNavigationsActions, - isReIndexShow, - isSectionsExpanded, - isDisabledReindexButton, - hasSections, - courseActions, - errors, - sections, -}) => ( - - - -); - -CourseOutlineHeaderActionsSlot.propTypes = { - isReIndexShow: PropTypes.bool.isRequired, - isSectionsExpanded: PropTypes.bool.isRequired, - isDisabledReindexButton: PropTypes.bool.isRequired, - headerNavigationsActions: PropTypes.shape({ - handleNewSection: PropTypes.func.isRequired, - handleReIndex: PropTypes.func.isRequired, - handleExpandAll: PropTypes.func.isRequired, - lmsLink: PropTypes.string.isRequired, - }).isRequired, - hasSections: PropTypes.bool.isRequired, - courseActions: PropTypes.shape({ - deletable: PropTypes.bool.isRequired, - draggable: PropTypes.bool.isRequired, - childAddable: PropTypes.bool.isRequired, - duplicable: PropTypes.bool.isRequired, - }).isRequired, - errors: PropTypes.shape({ - outlineIndexApi: PropTypes.shape({ - data: PropTypes.string, - type: PropTypes.string.isRequired, - }), - reindexApi: PropTypes.shape({ - data: PropTypes.string, - type: PropTypes.string.isRequired, - }), - sectionLoadingApi: PropTypes.shape({ - data: PropTypes.string, - type: PropTypes.string.isRequired, - }), - courseLaunchApi: PropTypes.shape({ - data: PropTypes.string, - type: PropTypes.string.isRequired, - }), - }), - sections: PropTypes.arrayOf( - PropTypes.shape({ - id: PropTypes.string.isRequired, - displayName: PropTypes.string.isRequired, - }), - ).isRequired, -}; - -export default CourseOutlineHeaderActionsSlot; diff --git a/src/plugin-slots/CourseOutlineHeaderActionsSlot/index.tsx b/src/plugin-slots/CourseOutlineHeaderActionsSlot/index.tsx new file mode 100644 index 0000000000..9eb9066063 --- /dev/null +++ b/src/plugin-slots/CourseOutlineHeaderActionsSlot/index.tsx @@ -0,0 +1,48 @@ +import { PluginSlot } from '@openedx/frontend-plugin-framework'; + +import HeaderNavigations, { HeaderNavigationsProps } from 'CourseAuthoring/course-outline/header-navigations/HeaderNavigations'; + +interface CourseOutlineHeaderActionsSlotProps extends HeaderNavigationsProps { + sections: Array<({ + id: string, + displayName: string, + })>, +} + +const CourseOutlineHeaderActionsSlot = ({ + headerNavigationsActions, + isReIndexShow, + isSectionsExpanded, + isDisabledReindexButton, + hasSections, + courseActions, + errors, + sections, +}: CourseOutlineHeaderActionsSlotProps) => ( + + + +); + +export default CourseOutlineHeaderActionsSlot; diff --git a/tsconfig.json b/tsconfig.json index 77abe775a2..456fd4d8f3 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -4,7 +4,8 @@ "outDir": "dist", "baseUrl": "./src", "paths": { - "@src/*": ["./*"] + "@src/*": ["./*"], + "CourseAuthoring/*": ["./*"] }, "types": ["jest", "@testing-library/jest-dom"] }, From 8fa0d089a6fccc58d12115f4190fe812f0da8050 Mon Sep 17 00:00:00 2001 From: Navin Karkera Date: Thu, 4 Dec 2025 18:36:15 +0530 Subject: [PATCH 03/22] feat: new header action buttons --- .env | 1 + .env.development | 1 + .env.test | 1 + .../header-navigations/HeaderActions.tsx | 102 ++++++++++++++++++ .../{messages.js => messages.ts} | 16 +++ src/index.jsx | 1 + .../CourseOutlineHeaderActionsSlot/index.tsx | 67 +++++++----- 7 files changed, 163 insertions(+), 26 deletions(-) create mode 100644 src/course-outline/header-navigations/HeaderActions.tsx rename src/course-outline/header-navigations/{messages.js => messages.ts} (71%) diff --git a/.env b/.env index 3237da4c31..76e0d02850 100644 --- a/.env +++ b/.env @@ -37,6 +37,7 @@ ENABLE_VIDEO_UPLOAD_PAGE_LINK_IN_CONTENT_DROPDOWN=false ENABLE_TAGGING_TAXONOMY_PAGES=true ENABLE_CERTIFICATE_PAGE=true ENABLE_COURSE_IMPORT_IN_LIBRARY=false +ENABLE_COURSE_OUTLINE_NEW_DESIGN=false BBB_LEARN_MORE_URL='' HOTJAR_APP_ID='' HOTJAR_VERSION=6 diff --git a/.env.development b/.env.development index 970902cff7..33aacbdb2f 100644 --- a/.env.development +++ b/.env.development @@ -38,6 +38,7 @@ ENABLE_ASSETS_PAGE=false ENABLE_VIDEO_UPLOAD_PAGE_LINK_IN_CONTENT_DROPDOWN=true ENABLE_CERTIFICATE_PAGE=true ENABLE_COURSE_IMPORT_IN_LIBRARY=true +ENABLE_COURSE_OUTLINE_NEW_DESIGN=true ENABLE_NEW_VIDEO_UPLOAD_PAGE=true ENABLE_TAGGING_TAXONOMY_PAGES=true BBB_LEARN_MORE_URL='' diff --git a/.env.test b/.env.test index 0e1e83d0cd..9ecfd073eb 100644 --- a/.env.test +++ b/.env.test @@ -34,6 +34,7 @@ ENABLE_ASSETS_PAGE=false ENABLE_VIDEO_UPLOAD_PAGE_LINK_IN_CONTENT_DROPDOWN=true ENABLE_CERTIFICATE_PAGE=true ENABLE_COURSE_IMPORT_IN_LIBRARY=true +ENABLE_COURSE_OUTLINE_NEW_DESIGN=false ENABLE_TAGGING_TAXONOMY_PAGES=true BBB_LEARN_MORE_URL='' INVITE_STUDENTS_EMAIL_TO="someone@domain.com" diff --git a/src/course-outline/header-navigations/HeaderActions.tsx b/src/course-outline/header-navigations/HeaderActions.tsx new file mode 100644 index 0000000000..6b89db557b --- /dev/null +++ b/src/course-outline/header-navigations/HeaderActions.tsx @@ -0,0 +1,102 @@ +import { useIntl } from '@edx/frontend-platform/i18n'; +import { + Button, Dropdown, Icon, OverlayTrigger, Stack, Tooltip, +} from '@openedx/paragon'; +import { + Add as IconAdd, AutoGraph, FindInPage, HelpOutline, InfoOutline, ViewSidebar, +} from '@openedx/paragon/icons'; + +import { OutlinePageErrors, XBlockActions } from '@src/data/types'; +import messages from './messages'; + +export interface HeaderActionsProps { + actions: { + handleNewSection: () => void, + lmsLink: string, + }, + courseActions: XBlockActions, + errors?: OutlinePageErrors, +} + +const HeaderActions = ({ + actions, + courseActions, + errors, +}: HeaderActionsProps) => { + const intl = useIntl(); + const { handleNewSection, lmsLink } = actions; + + return ( + + {courseActions.childAddable && ( + + {intl.formatMessage(messages.newSectionButtonTooltip)} + + )} + > + + + )} + + {intl.formatMessage(messages.viewLiveButtonTooltip)} + + )} + > + + + + + + + + + + + {intl.formatMessage(messages.infoButton)} + + + + + + {intl.formatMessage(messages.analyticsButton)} + + + + + + {intl.formatMessage(messages.helpButton)} + + + + + + + ); +}; + +export default HeaderActions; diff --git a/src/course-outline/header-navigations/messages.js b/src/course-outline/header-navigations/messages.ts similarity index 71% rename from src/course-outline/header-navigations/messages.js rename to src/course-outline/header-navigations/messages.ts index 588a2fa5d3..eaefe82268 100644 --- a/src/course-outline/header-navigations/messages.js +++ b/src/course-outline/header-navigations/messages.ts @@ -5,6 +5,22 @@ const messages = defineMessages({ id: 'course-authoring.course-outline.header-navigations.button.new-section', defaultMessage: 'New section', }, + addButton: { + id: 'course-authoring.course-outline.header-navigations.button.add-button', + defaultMessage: 'Add', + }, + infoButton: { + id: 'course-authoring.course-outline.header-navigations.button.infoButton', + defaultMessage: 'Info', + }, + analyticsButton: { + id: 'course-authoring.course-outline.header-navigations.button.analyticsButton', + defaultMessage: 'Analytics', + }, + helpButton: { + id: 'course-authoring.course-outline.header-navigations.button.helpButton', + defaultMessage: 'Help', + }, newSectionButtonTooltip: { id: 'course-authoring.course-outline.header-navigations.button.new-section.tooltip', defaultMessage: 'Click to add a new section', diff --git a/src/index.jsx b/src/index.jsx index 77b00c3e56..558e53e818 100755 --- a/src/index.jsx +++ b/src/index.jsx @@ -174,6 +174,7 @@ initialize({ ENABLE_VIDEO_UPLOAD_PAGE_LINK_IN_CONTENT_DROPDOWN: process.env.ENABLE_VIDEO_UPLOAD_PAGE_LINK_IN_CONTENT_DROPDOWN || 'false', ENABLE_CERTIFICATE_PAGE: process.env.ENABLE_CERTIFICATE_PAGE || 'false', ENABLE_COURSE_IMPORT_IN_LIBRARY: process.env.ENABLE_COURSE_IMPORT_IN_LIBRARY || 'false', + ENABLE_COURSE_OUTLINE_NEW_DESIGN: process.env.ENABLE_COURSE_OUTLINE_NEW_DESIGN || 'false', ENABLE_TAGGING_TAXONOMY_PAGES: process.env.ENABLE_TAGGING_TAXONOMY_PAGES || 'false', ENABLE_CHECKLIST_QUALITY: process.env.ENABLE_CHECKLIST_QUALITY || 'true', ENABLE_GRADING_METHOD_IN_PROBLEMS: process.env.ENABLE_GRADING_METHOD_IN_PROBLEMS === 'true', diff --git a/src/plugin-slots/CourseOutlineHeaderActionsSlot/index.tsx b/src/plugin-slots/CourseOutlineHeaderActionsSlot/index.tsx index 9eb9066063..452039afc9 100644 --- a/src/plugin-slots/CourseOutlineHeaderActionsSlot/index.tsx +++ b/src/plugin-slots/CourseOutlineHeaderActionsSlot/index.tsx @@ -1,6 +1,8 @@ import { PluginSlot } from '@openedx/frontend-plugin-framework'; +import { getConfig } from '@edx/frontend-platform'; import HeaderNavigations, { HeaderNavigationsProps } from 'CourseAuthoring/course-outline/header-navigations/HeaderNavigations'; +import HeaderActions from 'CourseAuthoring/course-outline/header-navigations/HeaderActions'; interface CourseOutlineHeaderActionsSlotProps extends HeaderNavigationsProps { sections: Array<({ @@ -18,31 +20,44 @@ const CourseOutlineHeaderActionsSlot = ({ courseActions, errors, sections, -}: CourseOutlineHeaderActionsSlotProps) => ( - - - -); +}: CourseOutlineHeaderActionsSlotProps) => { + const showNewActionsBar = getConfig().ENABLE_COURSE_OUTLINE_NEW_DESIGN?.toString().toLowerCase() === 'true'; + return ( + + {showNewActionsBar + ? ( + + ) + : ( + + )} + + ); +}; export default CourseOutlineHeaderActionsSlot; From dd93d01e75b6af66e0b7dbd89215cd63ba0380e1 Mon Sep 17 00:00:00 2001 From: Navin Karkera Date: Fri, 5 Dec 2025 11:01:49 +0530 Subject: [PATCH 04/22] feat: status bar --- src/course-outline/CourseOutline.tsx | 2 +- src/course-outline/data/slice.ts | 1 + src/course-outline/data/thunk.ts | 2 + src/course-outline/data/types.ts | 31 +-- src/course-outline/messages.js | 2 +- src/course-outline/status-bar/StatusBar.jsx | 220 ------------------ ...{StatusBar.test.jsx => StatusBar.test.tsx} | 0 src/course-outline/status-bar/StatusBar.tsx | 133 +++++++++++ 8 files changed, 156 insertions(+), 235 deletions(-) delete mode 100644 src/course-outline/status-bar/StatusBar.jsx rename src/course-outline/status-bar/{StatusBar.test.jsx => StatusBar.test.tsx} (100%) create mode 100644 src/course-outline/status-bar/StatusBar.tsx diff --git a/src/course-outline/CourseOutline.tsx b/src/course-outline/CourseOutline.tsx index eeeb46657a..35d7d426c7 100644 --- a/src/course-outline/CourseOutline.tsx +++ b/src/course-outline/CourseOutline.tsx @@ -314,7 +314,7 @@ const CourseOutline = () => { ) : null} ; isCustomRelativeDatesActive: boolean; currentSection: XBlock | {}; diff --git a/src/course-outline/messages.js b/src/course-outline/messages.js index f1f797e0dd..754f1e2792 100644 --- a/src/course-outline/messages.js +++ b/src/course-outline/messages.js @@ -7,7 +7,7 @@ const messages = defineMessages({ }, headingSubtitle: { id: 'course-authoring.course-outline.subTitle', - defaultMessage: 'Content', + defaultMessage: 'Course Outline', }, alertSuccessTitle: { id: 'course-authoring.course-outline.reindex.alert.success.title', diff --git a/src/course-outline/status-bar/StatusBar.jsx b/src/course-outline/status-bar/StatusBar.jsx deleted file mode 100644 index e71a2f925a..0000000000 --- a/src/course-outline/status-bar/StatusBar.jsx +++ /dev/null @@ -1,220 +0,0 @@ -import { useContext } from 'react'; -import moment from 'moment/moment'; -import PropTypes from 'prop-types'; -import { FormattedDate, useIntl } from '@edx/frontend-platform/i18n'; -import { getConfig } from '@edx/frontend-platform/config'; -import { - Button, Hyperlink, Form, Stack, useToggle, -} from '@openedx/paragon'; -import { Link } from 'react-router-dom'; -import { AppContext } from '@edx/frontend-platform/react'; - -import { ContentTagsDrawerSheet } from '../../content-tags-drawer'; -import TagCount from '../../generic/tag-count'; -import { useHelpUrls } from '../../help-urls/hooks'; -import { useWaffleFlags } from '../../data/apiHooks'; -import { VIDEO_SHARING_OPTIONS } from '../constants'; -import { useContentTagsCount } from '../../generic/data/apiHooks'; -import messages from './messages'; -import { getVideoSharingOptionText } from '../utils'; - -const StatusBarItem = ({ title, children }) => ( -
-
{title}
-
- {children} -
-
-); - -StatusBarItem.propTypes = { - title: PropTypes.string.isRequired, - children: PropTypes.node, -}; - -StatusBarItem.defaultProps = { - children: null, -}; - -const StatusBar = ({ - statusBarData, - isLoading, - courseId, - openEnableHighlightsModal, - handleVideoSharingOptionChange, -}) => { - const intl = useIntl(); - const { config } = useContext(AppContext); - const waffleFlags = useWaffleFlags(courseId); - - const { - courseReleaseDate, - highlightsEnabledForMessaging, - checklist, - isSelfPaced, - videoSharingEnabled, - videoSharingOptions, - } = statusBarData; - - const { - completedCourseLaunchChecks, - completedCourseBestPracticesChecks, - totalCourseLaunchChecks, - totalCourseBestPracticesChecks, - } = checklist; - - const courseReleaseDateObj = moment.utc(courseReleaseDate, 'MMM DD, YYYY [at] HH:mm UTC', true); - const checkListTitle = `${completedCourseLaunchChecks + completedCourseBestPracticesChecks}/${totalCourseLaunchChecks + totalCourseBestPracticesChecks}`; - const scheduleDestination = () => new URL(`settings/details/${courseId}#schedule`, config.STUDIO_BASE_URL).href; - - const { - contentHighlights: contentHighlightsUrl, - socialSharing: socialSharingUrl, - } = useHelpUrls(['contentHighlights', 'socialSharing']); - - const { data: courseTagCount } = useContentTagsCount(courseId); - - const [isManageTagsDrawerOpen, openManageTagsDrawer, closeManageTagsDrawer] = useToggle(false); - - if (isLoading) { - return null; - } - - return ( - <> - - - - {courseReleaseDateObj.isValid() ? ( - - ) : courseReleaseDate} - - - - - {isSelfPaced - ? intl.formatMessage(messages.pacingTypeSelfPaced) - : intl.formatMessage(messages.pacingTypeInstructorPaced)} - - - - - {checkListTitle} {intl.formatMessage(messages.checklistCompleted)} - - - -
- {highlightsEnabledForMessaging ? ( - - {intl.formatMessage(messages.highlightEmailsEnabled)} - - ) : ( - - )} - - {intl.formatMessage(messages.highlightEmailsLink)} - -
-
- {getConfig().ENABLE_TAGGING_TAXONOMY_PAGES === 'true' && ( - -
- - { /* eslint-disable-next-line jsx-a11y/anchor-is-valid */ } - - {intl.formatMessage(messages.courseManageTagsLink)} - -
-
- )} - {videoSharingEnabled && ( - - {intl.formatMessage(messages.videoSharingTitle)} - -
- handleVideoSharingOptionChange(e.target.value)} - > - {Object.values(VIDEO_SHARING_OPTIONS).map((option) => ( - - ))} - - - {intl.formatMessage(messages.videoSharingLink)} - -
-
- - )} -
- closeManageTagsDrawer()} - showSheet={isManageTagsDrawerOpen} - /> - - ); -}; - -StatusBar.propTypes = { - courseId: PropTypes.string.isRequired, - isLoading: PropTypes.bool.isRequired, - openEnableHighlightsModal: PropTypes.func.isRequired, - handleVideoSharingOptionChange: PropTypes.func.isRequired, - statusBarData: PropTypes.shape({ - courseReleaseDate: PropTypes.string.isRequired, - isSelfPaced: PropTypes.bool.isRequired, - checklist: PropTypes.shape({ - totalCourseLaunchChecks: PropTypes.number.isRequired, - completedCourseLaunchChecks: PropTypes.number.isRequired, - totalCourseBestPracticesChecks: PropTypes.number.isRequired, - completedCourseBestPracticesChecks: PropTypes.number.isRequired, - }), - highlightsEnabledForMessaging: PropTypes.bool.isRequired, - videoSharingEnabled: PropTypes.bool.isRequired, - videoSharingOptions: PropTypes.string.isRequired, - }).isRequired, -}; - -export default StatusBar; diff --git a/src/course-outline/status-bar/StatusBar.test.jsx b/src/course-outline/status-bar/StatusBar.test.tsx similarity index 100% rename from src/course-outline/status-bar/StatusBar.test.jsx rename to src/course-outline/status-bar/StatusBar.test.tsx diff --git a/src/course-outline/status-bar/StatusBar.tsx b/src/course-outline/status-bar/StatusBar.tsx new file mode 100644 index 0000000000..4234e960e9 --- /dev/null +++ b/src/course-outline/status-bar/StatusBar.tsx @@ -0,0 +1,133 @@ +import moment from 'moment/moment'; +import { FormattedDate, useIntl } from '@edx/frontend-platform/i18n'; +import { getConfig } from '@edx/frontend-platform/config'; +import { + Button, Hyperlink, Stack, +} from '@openedx/paragon'; +import { Link } from 'react-router-dom'; + +import { useHelpUrls } from '../../help-urls/hooks'; +import { useWaffleFlags } from '../../data/apiHooks'; +import messages from './messages'; +import { ReactNode } from 'react'; +import { CourseOutlineStatusBar } from '@src/course-outline/data/types'; + +interface StatusBarItemProps { + title: string, + children: ReactNode, +}; + +const StatusBarItem = ({ title, children }: StatusBarItemProps) => ( +
+
{title}
+
+ {children} +
+
+); + +interface StatusBarProps { + courseId: string, + isLoading: boolean, + openEnableHighlightsModal: () => void, + handleVideoSharingOptionChange: () => void, + statusBarData: CourseOutlineStatusBar, +}; + +const StatusBar = ({ + statusBarData, + isLoading, + courseId, + openEnableHighlightsModal, +}: StatusBarProps) => { + const intl = useIntl(); + const waffleFlags = useWaffleFlags(courseId); + + const { + endDate, + courseReleaseDate, + highlightsEnabledForMessaging, + checklist, + isSelfPaced, + } = statusBarData; + + const { + completedCourseLaunchChecks, + completedCourseBestPracticesChecks, + totalCourseLaunchChecks, + totalCourseBestPracticesChecks, + } = checklist; + + const courseReleaseDateObj = moment.utc(courseReleaseDate, 'MMM DD, YYYY [at] HH:mm UTC', true); + const endDateObj = moment.utc(endDate); + const checkListTitle = `${completedCourseLaunchChecks + completedCourseBestPracticesChecks}/${totalCourseLaunchChecks + totalCourseBestPracticesChecks}`; + const scheduleDestination = () => new URL(`settings/details/${courseId}#schedule`, getConfig().STUDIO_BASE_URL).href; + + const { + contentHighlights: contentHighlightsUrl, + } = useHelpUrls(['contentHighlights', 'socialSharing']); + + if (isLoading) { + return null; + } + + return ( + <> + + + + {courseReleaseDateObj.isValid() ? ( + + ) : courseReleaseDate} + + + + + {isSelfPaced + ? intl.formatMessage(messages.pacingTypeSelfPaced) + : intl.formatMessage(messages.pacingTypeInstructorPaced)} + + + + + {checkListTitle} {intl.formatMessage(messages.checklistCompleted)} + + + +
+ {highlightsEnabledForMessaging ? ( + + {intl.formatMessage(messages.highlightEmailsEnabled)} + + ) : ( + + )} + + {intl.formatMessage(messages.highlightEmailsLink)} + +
+
+
+ + ); +}; + +export default StatusBar; From 613a1b7ea9e1e01ade9cfcac1e89fb1967532b27 Mon Sep 17 00:00:00 2001 From: Navin Karkera Date: Fri, 5 Dec 2025 19:34:52 +0530 Subject: [PATCH 05/22] feat: new status bar in course outline --- src/course-outline/CourseOutline.tsx | 4 +- src/course-outline/status-bar/StatusBar.tsx | 166 ++++++++++---------- src/course-outline/status-bar/messages.js | 5 + 3 files changed, 88 insertions(+), 87 deletions(-) diff --git a/src/course-outline/CourseOutline.tsx b/src/course-outline/CourseOutline.tsx index 35d7d426c7..5228e45be8 100644 --- a/src/course-outline/CourseOutline.tsx +++ b/src/course-outline/CourseOutline.tsx @@ -316,6 +316,7 @@ const CourseOutline = () => { { courseId={courseId} isLoading={isLoading} statusBarData={statusBarData} - openEnableHighlightsModal={openEnableHighlightsModal} - handleVideoSharingOptionChange={handleVideoSharingOptionChange} + notificationCount={3} /> {!errors?.outlineIndexApi && (
diff --git a/src/course-outline/status-bar/StatusBar.tsx b/src/course-outline/status-bar/StatusBar.tsx index 4234e960e9..7f66d58b32 100644 --- a/src/course-outline/status-bar/StatusBar.tsx +++ b/src/course-outline/status-bar/StatusBar.tsx @@ -1,44 +1,82 @@ -import moment from 'moment/moment'; +import moment, { Moment } from 'moment/moment'; import { FormattedDate, useIntl } from '@edx/frontend-platform/i18n'; import { getConfig } from '@edx/frontend-platform/config'; -import { - Button, Hyperlink, Stack, -} from '@openedx/paragon'; +import { Badge, Icon, Stack } from '@openedx/paragon'; import { Link } from 'react-router-dom'; -import { useHelpUrls } from '../../help-urls/hooks'; import { useWaffleFlags } from '../../data/apiHooks'; import messages from './messages'; -import { ReactNode } from 'react'; +import { useMemo } from 'react'; import { CourseOutlineStatusBar } from '@src/course-outline/data/types'; +import { ChecklistRtl, NotificationsNone } from '@openedx/paragon/icons'; -interface StatusBarItemProps { - title: string, - children: ReactNode, -}; +const CourseDatesAndStatus = ({ startDate, endDate, startDateRaw, datesLink }: { + startDate: Moment; + endDate: Moment; + startDateRaw: string; + datesLink: string; +}) => { + if (!startDate.isValid()) { + // Returns string contained in startDate, i.e. `Set Date` + return <>{startDateRaw}; + } -const StatusBarItem = ({ title, children }: StatusBarItemProps) => ( -
-
{title}
-
- {children} -
-
-); + const courseStatus = useMemo(() => { + const now = moment().utc(); + return { + active: now.isBetween(startDate, endDate.isValid() ? endDate : undefined, undefined, '[]'), + upcoming: now.isBefore(startDate), + archived: endDate.isValid() && endDate.isBefore(now), + } + }, [startDate, endDate]); + + return ( + + {courseStatus.active + ? Active + : courseStatus.upcoming + ? Upcoming + : courseStatus.archived && + Archived + } + + + {endDate.isValid() && ( + <> + {" - "} + + + )} + + + ) +}; interface StatusBarProps { - courseId: string, - isLoading: boolean, - openEnableHighlightsModal: () => void, - handleVideoSharingOptionChange: () => void, - statusBarData: CourseOutlineStatusBar, + courseId: string; + isLoading: boolean; + statusBarData: CourseOutlineStatusBar; + notificationCount?: number; }; const StatusBar = ({ statusBarData, isLoading, courseId, - openEnableHighlightsModal, + notificationCount, }: StatusBarProps) => { const intl = useIntl(); const waffleFlags = useWaffleFlags(courseId); @@ -46,9 +84,7 @@ const StatusBar = ({ const { endDate, courseReleaseDate, - highlightsEnabledForMessaging, checklist, - isSelfPaced, } = statusBarData; const { @@ -63,70 +99,30 @@ const StatusBar = ({ const checkListTitle = `${completedCourseLaunchChecks + completedCourseBestPracticesChecks}/${totalCourseLaunchChecks + totalCourseBestPracticesChecks}`; const scheduleDestination = () => new URL(`settings/details/${courseId}#schedule`, getConfig().STUDIO_BASE_URL).href; - const { - contentHighlights: contentHighlightsUrl, - } = useHelpUrls(['contentHighlights', 'socialSharing']); - if (isLoading) { return null; } return ( - <> - - - - {courseReleaseDateObj.isValid() ? ( - - ) : courseReleaseDate} - - - - - {isSelfPaced - ? intl.formatMessage(messages.pacingTypeSelfPaced) - : intl.formatMessage(messages.pacingTypeInstructorPaced)} - - - - - {checkListTitle} {intl.formatMessage(messages.checklistCompleted)} - - - -
- {highlightsEnabledForMessaging ? ( - - {intl.formatMessage(messages.highlightEmailsEnabled)} - - ) : ( - - )} - - {intl.formatMessage(messages.highlightEmailsLink)} - -
-
-
- + + + {(notificationCount || 0) > 0 && + + {intl.formatMessage(messages.notificationMetadataTitle, { count: notificationCount })} + } + + + {checkListTitle} {intl.formatMessage(messages.checklistCompleted)} + + ); }; diff --git a/src/course-outline/status-bar/messages.js b/src/course-outline/status-bar/messages.js index 0f3f105737..d17ba4f46d 100644 --- a/src/course-outline/status-bar/messages.js +++ b/src/course-outline/status-bar/messages.js @@ -25,6 +25,11 @@ const messages = defineMessages({ id: 'course-authoring.course-outline.status-bar.checklists.completed', defaultMessage: 'completed', }, + notificationMetadataTitle: { + id: 'course-authoring.course-outline.status-bar.notification-metadata', + defaultMessage: '{count, plural, one {{count} notification} other {{count} notifications}}', + description: 'Metadata notifications text in course outline' + }, highlightEmailsTitle: { id: 'course-authoring.course-outline.status-bar.highlight-emails', defaultMessage: 'Course highlight emails', From 41685525f04993b4659f62fffac6cbfadab963c2 Mon Sep 17 00:00:00 2001 From: Navin Karkera Date: Fri, 5 Dec 2025 20:09:16 +0530 Subject: [PATCH 06/22] fixup! feat: new status bar in course outline --- src/course-outline/CourseOutline.tsx | 28 ++- .../header-navigations/messages.ts | 4 + src/course-outline/messages.js | 1 + .../status-bar/LegacyStatusBar.tsx | 200 ++++++++++++++++++ src/course-outline/status-bar/StatusBar.tsx | 10 +- 5 files changed, 230 insertions(+), 13 deletions(-) create mode 100644 src/course-outline/status-bar/LegacyStatusBar.tsx diff --git a/src/course-outline/CourseOutline.tsx b/src/course-outline/CourseOutline.tsx index 5228e45be8..bd457ef898 100644 --- a/src/course-outline/CourseOutline.tsx +++ b/src/course-outline/CourseOutline.tsx @@ -1,5 +1,6 @@ import { useState, useEffect, useCallback } from 'react'; import { useIntl } from '@edx/frontend-platform/i18n'; +import { getConfig } from '@edx/frontend-platform'; import { Container, Layout, @@ -44,7 +45,6 @@ import { getTimedExamsFlag, } from './data/selectors'; import { COURSE_BLOCK_NAMES } from './constants'; -import StatusBar from './status-bar/StatusBar'; import EnableHighlightsModal from './enable-highlights-modal/EnableHighlightsModal'; import SectionCard from './section-card/SectionCard'; import SubsectionCard from './subsection-card/SubsectionCard'; @@ -63,6 +63,8 @@ import { useCourseOutline } from './hooks'; import messages from './messages'; import { getTagsExportFile } from './data/api'; import OutlineAddChildButtons from './OutlineAddChildButtons'; +import { StatusBar } from './status-bar/StatusBar'; +import { LegacyStatusBar } from './status-bar/LegacyStatusBar'; const CourseOutline = () => { const intl = useIntl(); @@ -141,6 +143,7 @@ const CourseOutline = () => { resetScrollState, } = useCourseOutline({ courseId }); + const showNewActionsBar = getConfig().ENABLE_COURSE_OUTLINE_NEW_DESIGN?.toString().toLowerCase() === 'true'; // Use `setToastMessage` to show the toast. const [toastMessage, setToastMessage] = useState(null); @@ -341,12 +344,23 @@ const CourseOutline = () => {
- + {showNewActionsBar + ? ( + + ) : ( + + )} {!errors?.outlineIndexApi && (
{sections.length ? ( diff --git a/src/course-outline/header-navigations/messages.ts b/src/course-outline/header-navigations/messages.ts index eaefe82268..d77e79c46e 100644 --- a/src/course-outline/header-navigations/messages.ts +++ b/src/course-outline/header-navigations/messages.ts @@ -8,18 +8,22 @@ const messages = defineMessages({ addButton: { id: 'course-authoring.course-outline.header-navigations.button.add-button', defaultMessage: 'Add', + description: 'Add button text in course outline header', }, infoButton: { id: 'course-authoring.course-outline.header-navigations.button.infoButton', defaultMessage: 'Info', + description: 'Info button text in course outline header', }, analyticsButton: { id: 'course-authoring.course-outline.header-navigations.button.analyticsButton', defaultMessage: 'Analytics', + description: 'Analytics button text in course outline header', }, helpButton: { id: 'course-authoring.course-outline.header-navigations.button.helpButton', defaultMessage: 'Help', + description: 'Help button text in course outline header', }, newSectionButtonTooltip: { id: 'course-authoring.course-outline.header-navigations.button.new-section.tooltip', diff --git a/src/course-outline/messages.js b/src/course-outline/messages.js index 754f1e2792..8638497ecb 100644 --- a/src/course-outline/messages.js +++ b/src/course-outline/messages.js @@ -8,6 +8,7 @@ const messages = defineMessages({ headingSubtitle: { id: 'course-authoring.course-outline.subTitle', defaultMessage: 'Course Outline', + description: 'Course Outline heading subTitle.', }, alertSuccessTitle: { id: 'course-authoring.course-outline.reindex.alert.success.title', diff --git a/src/course-outline/status-bar/LegacyStatusBar.tsx b/src/course-outline/status-bar/LegacyStatusBar.tsx new file mode 100644 index 0000000000..650a5c0144 --- /dev/null +++ b/src/course-outline/status-bar/LegacyStatusBar.tsx @@ -0,0 +1,200 @@ +import moment from 'moment/moment'; +import { FormattedDate, useIntl } from '@edx/frontend-platform/i18n'; +import { getConfig } from '@edx/frontend-platform/config'; +import { + Button, Hyperlink, Form, Stack, useToggle, +} from '@openedx/paragon'; +import { Link } from 'react-router-dom'; + +import { ContentTagsDrawerSheet } from '../../content-tags-drawer'; +import TagCount from '../../generic/tag-count'; +import { useHelpUrls } from '../../help-urls/hooks'; +import { useWaffleFlags } from '../../data/apiHooks'; +import { VIDEO_SHARING_OPTIONS } from '../constants'; +import { useContentTagsCount } from '../../generic/data/apiHooks'; +import messages from './messages'; +import { getVideoSharingOptionText } from '../utils'; +import { ReactNode } from 'react'; +import { CourseOutlineStatusBar } from '@src/course-outline/data/types'; + +interface StatusBarItemProps { + title: string, + children: ReactNode, +}; + +const StatusBarItem = ({ title, children }: StatusBarItemProps) => ( +
+
{title}
+
+ {children} +
+
+); + +interface LegacyStatusBarProps { + courseId: string, + isLoading: boolean, + openEnableHighlightsModal: () => void, + handleVideoSharingOptionChange: (value: string) => void, + statusBarData: CourseOutlineStatusBar, +}; + +export const LegacyStatusBar = ({ + statusBarData, + isLoading, + courseId, + openEnableHighlightsModal, + handleVideoSharingOptionChange, +}: LegacyStatusBarProps) => { + const intl = useIntl(); + const waffleFlags = useWaffleFlags(courseId); + + const { + courseReleaseDate, + highlightsEnabledForMessaging, + checklist, + isSelfPaced, + videoSharingEnabled, + videoSharingOptions, + } = statusBarData; + + const { + completedCourseLaunchChecks, + completedCourseBestPracticesChecks, + totalCourseLaunchChecks, + totalCourseBestPracticesChecks, + } = checklist; + + const courseReleaseDateObj = moment.utc(courseReleaseDate, 'MMM DD, YYYY [at] HH:mm UTC', true); + const checkListTitle = `${completedCourseLaunchChecks + completedCourseBestPracticesChecks}/${totalCourseLaunchChecks + totalCourseBestPracticesChecks}`; + const scheduleDestination = () => new URL(`settings/details/${courseId}#schedule`, getConfig().STUDIO_BASE_URL).href; + + const { + contentHighlights: contentHighlightsUrl, + socialSharing: socialSharingUrl, + } = useHelpUrls(['contentHighlights', 'socialSharing']); + + const { data: courseTagCount } = useContentTagsCount(courseId); + + const [isManageTagsDrawerOpen, openManageTagsDrawer, closeManageTagsDrawer] = useToggle(false); + + if (isLoading) { + return null; + } + + return ( + <> + + + + {courseReleaseDateObj.isValid() ? ( + + ) : courseReleaseDate} + + + + + {isSelfPaced + ? intl.formatMessage(messages.pacingTypeSelfPaced) + : intl.formatMessage(messages.pacingTypeInstructorPaced)} + + + + + {checkListTitle} {intl.formatMessage(messages.checklistCompleted)} + + + +
+ {highlightsEnabledForMessaging ? ( + + {intl.formatMessage(messages.highlightEmailsEnabled)} + + ) : ( + + )} + + {intl.formatMessage(messages.highlightEmailsLink)} + +
+
+ {getConfig().ENABLE_TAGGING_TAXONOMY_PAGES === 'true' && ( + +
+ + { /* eslint-disable-next-line jsx-a11y/anchor-is-valid */ } + + {intl.formatMessage(messages.courseManageTagsLink)} + +
+
+ )} + {videoSharingEnabled && ( + + {intl.formatMessage(messages.videoSharingTitle)} + +
+ ) => handleVideoSharingOptionChange(e.target.value)} + > + {Object.values(VIDEO_SHARING_OPTIONS).map((option) => ( + + ))} + + + {intl.formatMessage(messages.videoSharingLink)} + +
+
+ + )} +
+ closeManageTagsDrawer()} + showSheet={isManageTagsDrawerOpen} + /> + + ); +}; diff --git a/src/course-outline/status-bar/StatusBar.tsx b/src/course-outline/status-bar/StatusBar.tsx index 7f66d58b32..445dfc2032 100644 --- a/src/course-outline/status-bar/StatusBar.tsx +++ b/src/course-outline/status-bar/StatusBar.tsx @@ -33,11 +33,11 @@ const CourseDatesAndStatus = ({ startDate, endDate, startDateRaw, datesLink }: { return ( {courseStatus.active - ? Active + ? Active : courseStatus.upcoming - ? Upcoming + ? Upcoming : courseStatus.archived && - Archived + Archived } ); }; - -export default StatusBar; From b302d3351c06d6a120ba2c19e6e3f79ca00386b6 Mon Sep 17 00:00:00 2001 From: Navin Karkera Date: Fri, 5 Dec 2025 20:42:09 +0530 Subject: [PATCH 07/22] feat: expand collapse button bar --- src/course-outline/CourseOutline.tsx | 55 +++++++++++++++++++--------- src/generic/sub-header/SubHeader.tsx | 2 +- 2 files changed, 38 insertions(+), 19 deletions(-) diff --git a/src/course-outline/CourseOutline.tsx b/src/course-outline/CourseOutline.tsx index bd457ef898..40e6ec95cf 100644 --- a/src/course-outline/CourseOutline.tsx +++ b/src/course-outline/CourseOutline.tsx @@ -8,9 +8,11 @@ import { TransitionReplace, Toast, StandardModal, + Button, + ActionRow, } from '@openedx/paragon'; import { Helmet } from 'react-helmet'; -import { CheckCircle as CheckCircleIcon } from '@openedx/paragon/icons'; +import { CheckCircle as CheckCircleIcon, CloseFullscreen, OpenInFull } from '@openedx/paragon/icons'; import { useSelector } from 'react-redux'; import { arrayMove, @@ -61,6 +63,7 @@ import { } from './drag-helper/utils'; import { useCourseOutline } from './hooks'; import messages from './messages'; +import headerMessages from './header-navigations/messages'; import { getTagsExportFile } from './data/api'; import OutlineAddChildButtons from './OutlineAddChildButtons'; import { StatusBar } from './status-bar/StatusBar'; @@ -333,6 +336,24 @@ const CourseOutline = () => { /> )} /> + {showNewActionsBar + ? ( + + ) : ( + + )} +
{
+ {showNewActionsBar && + {Boolean(sectionsList.length) && ( + + )} + }
- {showNewActionsBar - ? ( - - ) : ( - - )} {!errors?.outlineIndexApi && (
{sections.length ? ( diff --git a/src/generic/sub-header/SubHeader.tsx b/src/generic/sub-header/SubHeader.tsx index eed36113e0..ededd579fc 100644 --- a/src/generic/sub-header/SubHeader.tsx +++ b/src/generic/sub-header/SubHeader.tsx @@ -26,7 +26,7 @@ const SubHeader = ({ hideBorder = false, withSubHeaderContent, }: SubHeaderProps) => ( -
+
{breadcrumbs && (
{breadcrumbs}
)} From ee811e456bb5f7ce8bfd685a7f120aa0bff6370d Mon Sep 17 00:00:00 2001 From: Navin Karkera Date: Fri, 5 Dec 2025 21:22:46 +0530 Subject: [PATCH 08/22] refactor: status bar --- src/course-outline/CourseOutline.tsx | 30 ++--- ...sBar.test.tsx => LegacyStatusBar.test.tsx} | 18 +-- .../status-bar/LegacyStatusBar.tsx | 10 +- src/course-outline/status-bar/StatusBar.tsx | 107 ++++++++++-------- src/course-outline/status-bar/messages.js | 2 +- 5 files changed, 89 insertions(+), 78 deletions(-) rename src/course-outline/status-bar/{StatusBar.test.tsx => LegacyStatusBar.test.tsx} (91%) diff --git a/src/course-outline/CourseOutline.tsx b/src/course-outline/CourseOutline.tsx index 40e6ec95cf..8d04362f81 100644 --- a/src/course-outline/CourseOutline.tsx +++ b/src/course-outline/CourseOutline.tsx @@ -353,7 +353,7 @@ const CourseOutline = () => { handleVideoSharingOptionChange={handleVideoSharingOptionChange} /> )} -
+
{
- {showNewActionsBar && + {showNewActionsBar && ( + {Boolean(sectionsList.length) && ( - + )} - } + + )}
{!errors?.outlineIndexApi && (
diff --git a/src/course-outline/status-bar/StatusBar.test.tsx b/src/course-outline/status-bar/LegacyStatusBar.test.tsx similarity index 91% rename from src/course-outline/status-bar/StatusBar.test.tsx rename to src/course-outline/status-bar/LegacyStatusBar.test.tsx index c57613ae04..ba5f4d1f4b 100644 --- a/src/course-outline/status-bar/StatusBar.test.tsx +++ b/src/course-outline/status-bar/LegacyStatusBar.test.tsx @@ -1,4 +1,3 @@ -import React from 'react'; import { render, fireEvent } from '@testing-library/react'; import { IntlProvider } from '@edx/frontend-platform/i18n'; import { AppProvider } from '@edx/frontend-platform/react'; @@ -6,10 +5,11 @@ import { initializeMockApp } from '@edx/frontend-platform'; import { getConfig, setConfig } from '@edx/frontend-platform/config'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; -import StatusBar from './StatusBar'; import messages from './messages'; import initializeStore from '../../store'; import { VIDEO_SHARING_OPTIONS } from '../constants'; +import { LegacyStatusBar, LegacyStatusBarProps } from './LegacyStatusBar'; +import { CourseOutlineStatusBar } from '../data/types'; let store; const mockPathname = '/foo-bar'; @@ -37,9 +37,10 @@ jest.mock('../../help-urls/hooks', () => ({ }), })); -const statusBarData = { +const statusBarData: CourseOutlineStatusBar = { courseReleaseDate: 'Feb 05, 2013 at 05:00 UTC', isSelfPaced: true, + endDate: 'Feb 05, 2014 at 05:00 UTC', checklist: { totalCourseLaunchChecks: 5, completedCourseLaunchChecks: 1, @@ -47,18 +48,17 @@ const statusBarData = { completedCourseBestPracticesChecks: 1, }, highlightsEnabledForMessaging: true, - highlightsDocUrl: 'https://example.com/highlights-doc', videoSharingEnabled: true, videoSharingOptions: VIDEO_SHARING_OPTIONS.allOn, }; const queryClient = new QueryClient(); -const renderComponent = (props) => render( +const renderComponent = (props?: Partial) => render( - render( , ); -describe('', () => { +describe('', () => { beforeEach(() => { initializeMockApp({ authenticatedUser: { @@ -84,7 +84,7 @@ describe('', () => { store = initializeStore(); }); - it('renders StatusBar component correctly', () => { + it('renders LegacyStatusBar component correctly', () => { const { getByText } = renderComponent(); expect(getByText(messages.startDateTitle.defaultMessage)).toBeInTheDocument(); @@ -102,7 +102,7 @@ describe('', () => { expect(getByText(messages.videoSharingTitle.defaultMessage)).toBeInTheDocument(); }); - it('renders StatusBar when isSelfPaced is false', () => { + it('renders LegacyStatusBar when isSelfPaced is false', () => { const { getByText } = renderComponent({ statusBarData: { ...statusBarData, diff --git a/src/course-outline/status-bar/LegacyStatusBar.tsx b/src/course-outline/status-bar/LegacyStatusBar.tsx index 650a5c0144..05cce62609 100644 --- a/src/course-outline/status-bar/LegacyStatusBar.tsx +++ b/src/course-outline/status-bar/LegacyStatusBar.tsx @@ -6,6 +6,8 @@ import { } from '@openedx/paragon'; import { Link } from 'react-router-dom'; +import { ReactNode } from 'react'; +import { CourseOutlineStatusBar } from '@src/course-outline/data/types'; import { ContentTagsDrawerSheet } from '../../content-tags-drawer'; import TagCount from '../../generic/tag-count'; import { useHelpUrls } from '../../help-urls/hooks'; @@ -14,13 +16,11 @@ import { VIDEO_SHARING_OPTIONS } from '../constants'; import { useContentTagsCount } from '../../generic/data/apiHooks'; import messages from './messages'; import { getVideoSharingOptionText } from '../utils'; -import { ReactNode } from 'react'; -import { CourseOutlineStatusBar } from '@src/course-outline/data/types'; interface StatusBarItemProps { title: string, children: ReactNode, -}; +} const StatusBarItem = ({ title, children }: StatusBarItemProps) => (
@@ -31,13 +31,13 @@ const StatusBarItem = ({ title, children }: StatusBarItemProps) => (
); -interface LegacyStatusBarProps { +export interface LegacyStatusBarProps { courseId: string, isLoading: boolean, openEnableHighlightsModal: () => void, handleVideoSharingOptionChange: (value: string) => void, statusBarData: CourseOutlineStatusBar, -}; +} export const LegacyStatusBar = ({ statusBarData, diff --git a/src/course-outline/status-bar/StatusBar.tsx b/src/course-outline/status-bar/StatusBar.tsx index 445dfc2032..e6eea9b0d7 100644 --- a/src/course-outline/status-bar/StatusBar.tsx +++ b/src/course-outline/status-bar/StatusBar.tsx @@ -4,13 +4,30 @@ import { getConfig } from '@edx/frontend-platform/config'; import { Badge, Icon, Stack } from '@openedx/paragon'; import { Link } from 'react-router-dom'; -import { useWaffleFlags } from '../../data/apiHooks'; -import messages from './messages'; -import { useMemo } from 'react'; import { CourseOutlineStatusBar } from '@src/course-outline/data/types'; import { ChecklistRtl, NotificationsNone } from '@openedx/paragon/icons'; +import messages from './messages'; +import { useWaffleFlags } from '../../data/apiHooks'; -const CourseDatesAndStatus = ({ startDate, endDate, startDateRaw, datesLink }: { +const CourseBadge = ({ startDate, endDate }: { startDate: Moment, endDate: Moment }) => { + const now = moment().utc(); + switch (true) { + case !startDate.isValid(): + return null; + case now.isBetween(startDate, endDate.isValid() ? endDate : undefined, undefined, '[]'): + return Active; + case now.isBefore(startDate): + return Upcoming; + case endDate.isValid() && endDate.isBefore(now): + return Archived; + default: + return null; + } +}; + +const CourseDates = ({ + startDate, endDate, startDateRaw, datesLink, +}: { startDate: Moment; endDate: Moment; startDateRaw: string; @@ -18,51 +35,40 @@ const CourseDatesAndStatus = ({ startDate, endDate, startDateRaw, datesLink }: { }) => { if (!startDate.isValid()) { // Returns string contained in startDate, i.e. `Set Date` - return <>{startDateRaw}; - } - - const courseStatus = useMemo(() => { - const now = moment().utc(); - return { - active: now.isBetween(startDate, endDate.isValid() ? endDate : undefined, undefined, '[]'), - upcoming: now.isBefore(startDate), - archived: endDate.isValid() && endDate.isBefore(now), - } - }, [startDate, endDate]); - - return ( - - {courseStatus.active - ? Active - : courseStatus.upcoming - ? Upcoming - : courseStatus.archived && - Archived - } + return ( - - {endDate.isValid() && ( - <> - {" - "} - - - )} + {startDateRaw} - - ) + ); + } + + return ( + + + {endDate.isValid() && ( + <> + {' - '} + + + )} + + ); }; interface StatusBarProps { @@ -70,7 +76,7 @@ interface StatusBarProps { isLoading: boolean; statusBarData: CourseOutlineStatusBar; notificationCount?: number; -}; +} export const StatusBar = ({ statusBarData, @@ -105,16 +111,19 @@ export const StatusBar = ({ return ( - + - {(notificationCount || 0) > 0 && + {(notificationCount || 0) > 0 && ( + {intl.formatMessage(messages.notificationMetadataTitle, { count: notificationCount })} - } + + )} Date: Fri, 12 Dec 2025 11:44:10 +0530 Subject: [PATCH 09/22] test: fix course outline test --- src/course-outline/CourseOutline.test.tsx | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/src/course-outline/CourseOutline.test.tsx b/src/course-outline/CourseOutline.test.tsx index 0177900f84..37d3073c23 100644 --- a/src/course-outline/CourseOutline.test.tsx +++ b/src/course-outline/CourseOutline.test.tsx @@ -182,12 +182,10 @@ describe('', () => { }); it('render CourseOutline component correctly', async () => { - const { getByText } = renderComponent(); + renderComponent(); - await waitFor(() => { - expect(getByText(messages.headingTitle.defaultMessage)).toBeInTheDocument(); - expect(getByText(messages.headingSubtitle.defaultMessage)).toBeInTheDocument(); - }); + expect(await screen.findByText("Demonstration Course")).toBeInTheDocument(); + expect(await screen.findByText(messages.headingSubtitle.defaultMessage)).toBeInTheDocument(); }); it('logs an error when syncDiscussionsTopics encounters an API failure', async () => { From 387b224f98b859902f07a97fb30bffb84d25f9a3 Mon Sep 17 00:00:00 2001 From: Navin Karkera Date: Fri, 12 Dec 2025 11:47:32 +0530 Subject: [PATCH 10/22] fix: subheader typing --- src/course-outline/CourseOutline.test.tsx | 2 +- src/generic/sub-header/SubHeader.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/course-outline/CourseOutline.test.tsx b/src/course-outline/CourseOutline.test.tsx index 37d3073c23..78607712f8 100644 --- a/src/course-outline/CourseOutline.test.tsx +++ b/src/course-outline/CourseOutline.test.tsx @@ -184,7 +184,7 @@ describe('', () => { it('render CourseOutline component correctly', async () => { renderComponent(); - expect(await screen.findByText("Demonstration Course")).toBeInTheDocument(); + expect(await screen.findByText('Demonstration Course')).toBeInTheDocument(); expect(await screen.findByText(messages.headingSubtitle.defaultMessage)).toBeInTheDocument(); }); diff --git a/src/generic/sub-header/SubHeader.tsx b/src/generic/sub-header/SubHeader.tsx index ededd579fc..d51d02fd30 100644 --- a/src/generic/sub-header/SubHeader.tsx +++ b/src/generic/sub-header/SubHeader.tsx @@ -24,7 +24,7 @@ const SubHeader = ({ headerActions, titleActions, hideBorder = false, - withSubHeaderContent, + withSubHeaderContent = true, }: SubHeaderProps) => (
{breadcrumbs && ( From dc1d7628337aa40b70af143a66b9ede2cc209787 Mon Sep 17 00:00:00 2001 From: Navin Karkera Date: Fri, 12 Dec 2025 12:45:09 +0530 Subject: [PATCH 11/22] test: status bar --- src/course-outline/CourseOutline.tsx | 2 + .../status-bar/StatusBar.test.tsx | 74 +++++++++++++++++++ src/course-outline/status-bar/StatusBar.tsx | 3 +- 3 files changed, 78 insertions(+), 1 deletion(-) create mode 100644 src/course-outline/status-bar/StatusBar.test.tsx diff --git a/src/course-outline/CourseOutline.tsx b/src/course-outline/CourseOutline.tsx index 8d04362f81..cacb2fe314 100644 --- a/src/course-outline/CourseOutline.tsx +++ b/src/course-outline/CourseOutline.tsx @@ -146,6 +146,8 @@ const CourseOutline = () => { resetScrollState, } = useCourseOutline({ courseId }); + // Show the new actions bar if it is enabled in the configuration. + // This is a temporary flag until the new design feature is fully implemented. const showNewActionsBar = getConfig().ENABLE_COURSE_OUTLINE_NEW_DESIGN?.toString().toLowerCase() === 'true'; // Use `setToastMessage` to show the toast. const [toastMessage, setToastMessage] = useState(null); diff --git a/src/course-outline/status-bar/StatusBar.test.tsx b/src/course-outline/status-bar/StatusBar.test.tsx new file mode 100644 index 0000000000..84050cfc51 --- /dev/null +++ b/src/course-outline/status-bar/StatusBar.test.tsx @@ -0,0 +1,74 @@ +import messages from './messages'; +import { VIDEO_SHARING_OPTIONS } from '../constants'; +import { CourseOutlineStatusBar } from '../data/types'; +import { StatusBar, StatusBarProps } from './StatusBar'; +import { initializeMocks, render, screen } from '../../testUtils'; + +const courseId = 'course-v1:123'; +const isLoading = false; + +const statusBarData: CourseOutlineStatusBar = { + courseReleaseDate: 'Feb 05, 2013 at 05:00 UTC', + isSelfPaced: true, + endDate: '2013-04-09T00:00:00Z', + checklist: { + totalCourseLaunchChecks: 5, + completedCourseLaunchChecks: 1, + totalCourseBestPracticesChecks: 4, + completedCourseBestPracticesChecks: 1, + }, + highlightsEnabledForMessaging: true, + videoSharingEnabled: true, + videoSharingOptions: VIDEO_SHARING_OPTIONS.allOn, +}; + +const renderComponent = (props?: Partial) => render( + +); + +describe('', () => { + beforeEach(() => { + initializeMocks(); + jest.useFakeTimers(); + jest.setSystemTime(new Date('2013-03-05')); + }); + + it('renders StatusBar component correctly', async () => { + renderComponent(); + + expect(await screen.findByText('Feb 05, 2013 - Apr 09, 2013')).toBeInTheDocument(); + expect(await screen.findByText(`2/9 ${messages.checklistCompleted.defaultMessage}`)).toBeInTheDocument(); + expect(await screen.findByText(`Active`)).toBeInTheDocument(); + }); + + it('renders Archived Badge', async () => { + jest.setSystemTime(new Date('2014-03-05')); + renderComponent(); + expect(await screen.findByText(`Archived`)).toBeInTheDocument(); + }); + + it('renders Upcoming Badge', async () => { + jest.setSystemTime(new Date('2012-03-05')); + renderComponent(); + expect(await screen.findByText(`Upcoming`)).toBeInTheDocument(); + }); + + it('renders set date link if date is not set', async () => { + renderComponent({ statusBarData: { + ...statusBarData, + courseReleaseDate: 'Set Date', + }}); + expect(await screen.findByText(`Set Date`)).toBeInTheDocument(); + }); + + it('not render component when isLoading is true', async () => { + renderComponent({ isLoading: true }); + + expect(await screen.findByTestId('redux-provider')).toBeEmptyDOMElement(); + }); +}); diff --git a/src/course-outline/status-bar/StatusBar.tsx b/src/course-outline/status-bar/StatusBar.tsx index e6eea9b0d7..fb6baf6f8b 100644 --- a/src/course-outline/status-bar/StatusBar.tsx +++ b/src/course-outline/status-bar/StatusBar.tsx @@ -21,6 +21,7 @@ const CourseBadge = ({ startDate, endDate }: { startDate: Moment, endDate: Momen case endDate.isValid() && endDate.isBefore(now): return Archived; default: + // istanbul ignore next: this should not happen return null; } }; @@ -71,7 +72,7 @@ const CourseDates = ({ ); }; -interface StatusBarProps { +export interface StatusBarProps { courseId: string; isLoading: boolean; statusBarData: CourseOutlineStatusBar; From 653c6ccc46d7ce86c95ad8038e4e0a82de390775 Mon Sep 17 00:00:00 2001 From: Navin Karkera Date: Fri, 12 Dec 2025 13:04:35 +0530 Subject: [PATCH 12/22] refactor: internationalize status bar badge texts --- src/course-outline/status-bar/StatusBar.tsx | 14 ++++++++++---- .../status-bar/{messages.js => messages.ts} | 15 +++++++++++++++ 2 files changed, 25 insertions(+), 4 deletions(-) rename src/course-outline/status-bar/{messages.js => messages.ts} (80%) diff --git a/src/course-outline/status-bar/StatusBar.tsx b/src/course-outline/status-bar/StatusBar.tsx index fb6baf6f8b..ff6346748b 100644 --- a/src/course-outline/status-bar/StatusBar.tsx +++ b/src/course-outline/status-bar/StatusBar.tsx @@ -1,5 +1,5 @@ import moment, { Moment } from 'moment/moment'; -import { FormattedDate, useIntl } from '@edx/frontend-platform/i18n'; +import { FormattedDate, FormattedMessage, useIntl } from '@edx/frontend-platform/i18n'; import { getConfig } from '@edx/frontend-platform/config'; import { Badge, Icon, Stack } from '@openedx/paragon'; import { Link } from 'react-router-dom'; @@ -15,11 +15,17 @@ const CourseBadge = ({ startDate, endDate }: { startDate: Moment, endDate: Momen case !startDate.isValid(): return null; case now.isBetween(startDate, endDate.isValid() ? endDate : undefined, undefined, '[]'): - return Active; + return + + ; case now.isBefore(startDate): - return Upcoming; + return + + ; case endDate.isValid() && endDate.isBefore(now): - return Archived; + return + + ; default: // istanbul ignore next: this should not happen return null; diff --git a/src/course-outline/status-bar/messages.js b/src/course-outline/status-bar/messages.ts similarity index 80% rename from src/course-outline/status-bar/messages.js rename to src/course-outline/status-bar/messages.ts index ceba03859c..aa5bfbc23b 100644 --- a/src/course-outline/status-bar/messages.js +++ b/src/course-outline/status-bar/messages.ts @@ -76,6 +76,21 @@ const messages = defineMessages({ id: 'course-authoring.course-outline.status-bar.video-sharing.allOn.text', defaultMessage: 'All Videos', }, + activeBadgeText: { + id: 'course-authoring.course-outline.status-bar.active.badge.text', + defaultMessage: 'Active', + description: 'Active Badge shown in course outline when the course is active, i.e., course has started and not ended yet.' + }, + archivedBadgeText: { + id: 'course-authoring.course-outline.status-bar.archived.badge.text', + defaultMessage: 'Archived', + description: 'Archived Badge shown in course outline when the course is archived, i.e., ended' + }, + upcomingBadgeText: { + id: 'course-authoring.course-outline.status-bar.upcoming.badge.text', + defaultMessage: 'Upcoming', + description: 'Upcoming Badge shown in course outline when the course has not started yet.' + }, }); export default messages; From 24872965b991b60ea89ca0db76cab2aed40954f2 Mon Sep 17 00:00:00 2001 From: Navin Karkera Date: Fri, 12 Dec 2025 13:04:43 +0530 Subject: [PATCH 13/22] test: header actions --- .../header-navigations/HeaderActions.test.tsx | 60 +++++++++++++++++++ .../header-navigations/HeaderActions.tsx | 2 +- .../header-navigations/messages.ts | 5 ++ 3 files changed, 66 insertions(+), 1 deletion(-) create mode 100644 src/course-outline/header-navigations/HeaderActions.test.tsx diff --git a/src/course-outline/header-navigations/HeaderActions.test.tsx b/src/course-outline/header-navigations/HeaderActions.test.tsx new file mode 100644 index 0000000000..cebd1c3f30 --- /dev/null +++ b/src/course-outline/header-navigations/HeaderActions.test.tsx @@ -0,0 +1,60 @@ +import userEvent from '@testing-library/user-event'; + +import { + fireEvent, initializeMocks, render, screen, waitFor, +} from '@src/testUtils'; +import messages from './messages'; +import HeaderActions, { HeaderActionsProps } from './HeaderActions'; + +const handleNewSectionMock = jest.fn(); + +const headerNavigationsActions = { + handleNewSection: handleNewSectionMock, + lmsLink: '', +}; + +const courseActions = { + draggable: true, + childAddable: true, + deletable: true, + duplicable: true, +}; + +const renderComponent = (props?: Partial) => render( + , +); + +describe('', () => { + beforeEach(() => { + initializeMocks(); + }); + + it('render HeaderActions component correctly', async () => { + renderComponent(); + + expect(await screen.findByRole('button', { name: messages.addButton.defaultMessage })).toBeInTheDocument(); + expect(await screen.findByRole('button', { name: messages.viewLiveButton.defaultMessage })).toBeInTheDocument(); + expect(await screen.findByRole('button', { name: messages.moreActionsButtonAriaLabel.defaultMessage })).toBeInTheDocument(); + }); + + it('calls the correct handlers when clicking buttons', async () => { + renderComponent(); + + const addButton = await screen.findByRole('button', { name: messages.addButton.defaultMessage }); + fireEvent.click(addButton); + expect(handleNewSectionMock).toHaveBeenCalledTimes(1); + }); + + it('disables new section button if course outline fetch fails', async () => { + renderComponent({ + errors: { outlineIndexApi: { data: 'some error', type: 'serverError' } }, + }); + + expect(await screen.findByRole('button', { name: messages.addButton.defaultMessage })).toBeInTheDocument(); + expect(await screen.findByRole('button', { name: messages.addButton.defaultMessage })).toBeDisabled(); + }); +}); diff --git a/src/course-outline/header-navigations/HeaderActions.tsx b/src/course-outline/header-navigations/HeaderActions.tsx index 6b89db557b..f54f69f60e 100644 --- a/src/course-outline/header-navigations/HeaderActions.tsx +++ b/src/course-outline/header-navigations/HeaderActions.tsx @@ -69,7 +69,7 @@ const HeaderActions = ({ id="dropdown-toggle-with-iconbutton" as={Button} variant="outline-primary" - aria-label="More actions" + aria-label={intl.formatMessage(messages.moreActionsButtonAriaLabel)} > diff --git a/src/course-outline/header-navigations/messages.ts b/src/course-outline/header-navigations/messages.ts index d77e79c46e..1d3c2cf948 100644 --- a/src/course-outline/header-navigations/messages.ts +++ b/src/course-outline/header-navigations/messages.ts @@ -49,6 +49,11 @@ const messages = defineMessages({ id: 'course-authoring.course-outline.header-navigations.button.view-live', defaultMessage: 'View live', }, + moreActionsButtonAriaLabel: { + id: 'course-authoring.course-outline.header-navigations.button.more-actions.aria-label', + defaultMessage: 'More actions', + description: 'More actions button aria label in course outline' + }, viewLiveButtonTooltip: { id: 'course-authoring.course-outline.header-navigations.button.view-live.tooltip', defaultMessage: 'Click to open the courseware in the LMS in a new tab', From a78653f3eef8c441a7bb03cabe2b0d02ef971b79 Mon Sep 17 00:00:00 2001 From: Navin Karkera Date: Fri, 12 Dec 2025 16:02:34 +0530 Subject: [PATCH 14/22] chore: fix lint issues --- .../header-navigations/HeaderActions.test.tsx | 4 +--- .../header-navigations/messages.ts | 2 +- .../status-bar/StatusBar.test.tsx | 20 +++++++++------- src/course-outline/status-bar/StatusBar.tsx | 24 ++++++++++++------- src/course-outline/status-bar/messages.ts | 6 ++--- 5 files changed, 31 insertions(+), 25 deletions(-) diff --git a/src/course-outline/header-navigations/HeaderActions.test.tsx b/src/course-outline/header-navigations/HeaderActions.test.tsx index cebd1c3f30..07fd29b5db 100644 --- a/src/course-outline/header-navigations/HeaderActions.test.tsx +++ b/src/course-outline/header-navigations/HeaderActions.test.tsx @@ -1,7 +1,5 @@ -import userEvent from '@testing-library/user-event'; - import { - fireEvent, initializeMocks, render, screen, waitFor, + fireEvent, initializeMocks, render, screen, } from '@src/testUtils'; import messages from './messages'; import HeaderActions, { HeaderActionsProps } from './HeaderActions'; diff --git a/src/course-outline/header-navigations/messages.ts b/src/course-outline/header-navigations/messages.ts index 1d3c2cf948..ec4d91754d 100644 --- a/src/course-outline/header-navigations/messages.ts +++ b/src/course-outline/header-navigations/messages.ts @@ -52,7 +52,7 @@ const messages = defineMessages({ moreActionsButtonAriaLabel: { id: 'course-authoring.course-outline.header-navigations.button.more-actions.aria-label', defaultMessage: 'More actions', - description: 'More actions button aria label in course outline' + description: 'More actions button aria label in course outline', }, viewLiveButtonTooltip: { id: 'course-authoring.course-outline.header-navigations.button.view-live.tooltip', diff --git a/src/course-outline/status-bar/StatusBar.test.tsx b/src/course-outline/status-bar/StatusBar.test.tsx index 84050cfc51..ae6e648791 100644 --- a/src/course-outline/status-bar/StatusBar.test.tsx +++ b/src/course-outline/status-bar/StatusBar.test.tsx @@ -28,7 +28,7 @@ const renderComponent = (props?: Partial) => render( isLoading={isLoading} statusBarData={statusBarData} {...props} - /> + />, ); describe('', () => { @@ -43,27 +43,29 @@ describe('', () => { expect(await screen.findByText('Feb 05, 2013 - Apr 09, 2013')).toBeInTheDocument(); expect(await screen.findByText(`2/9 ${messages.checklistCompleted.defaultMessage}`)).toBeInTheDocument(); - expect(await screen.findByText(`Active`)).toBeInTheDocument(); + expect(await screen.findByText('Active')).toBeInTheDocument(); }); it('renders Archived Badge', async () => { jest.setSystemTime(new Date('2014-03-05')); renderComponent(); - expect(await screen.findByText(`Archived`)).toBeInTheDocument(); + expect(await screen.findByText('Archived')).toBeInTheDocument(); }); it('renders Upcoming Badge', async () => { jest.setSystemTime(new Date('2012-03-05')); renderComponent(); - expect(await screen.findByText(`Upcoming`)).toBeInTheDocument(); + expect(await screen.findByText('Upcoming')).toBeInTheDocument(); }); it('renders set date link if date is not set', async () => { - renderComponent({ statusBarData: { - ...statusBarData, - courseReleaseDate: 'Set Date', - }}); - expect(await screen.findByText(`Set Date`)).toBeInTheDocument(); + renderComponent({ + statusBarData: { + ...statusBarData, + courseReleaseDate: 'Set Date', + }, + }); + expect(await screen.findByText('Set Date')).toBeInTheDocument(); }); it('not render component when isLoading is true', async () => { diff --git a/src/course-outline/status-bar/StatusBar.tsx b/src/course-outline/status-bar/StatusBar.tsx index ff6346748b..b9a5f26b8e 100644 --- a/src/course-outline/status-bar/StatusBar.tsx +++ b/src/course-outline/status-bar/StatusBar.tsx @@ -15,17 +15,23 @@ const CourseBadge = ({ startDate, endDate }: { startDate: Moment, endDate: Momen case !startDate.isValid(): return null; case now.isBetween(startDate, endDate.isValid() ? endDate : undefined, undefined, '[]'): - return - - ; + return ( + + + + ); case now.isBefore(startDate): - return - - ; + return ( + + + + ); case endDate.isValid() && endDate.isBefore(now): - return - - ; + return ( + + + + ); default: // istanbul ignore next: this should not happen return null; diff --git a/src/course-outline/status-bar/messages.ts b/src/course-outline/status-bar/messages.ts index aa5bfbc23b..f13f70131d 100644 --- a/src/course-outline/status-bar/messages.ts +++ b/src/course-outline/status-bar/messages.ts @@ -79,17 +79,17 @@ const messages = defineMessages({ activeBadgeText: { id: 'course-authoring.course-outline.status-bar.active.badge.text', defaultMessage: 'Active', - description: 'Active Badge shown in course outline when the course is active, i.e., course has started and not ended yet.' + description: 'Active Badge shown in course outline when the course is active, i.e., course has started and not ended yet.', }, archivedBadgeText: { id: 'course-authoring.course-outline.status-bar.archived.badge.text', defaultMessage: 'Archived', - description: 'Archived Badge shown in course outline when the course is archived, i.e., ended' + description: 'Archived Badge shown in course outline when the course is archived, i.e., ended', }, upcomingBadgeText: { id: 'course-authoring.course-outline.status-bar.upcoming.badge.text', defaultMessage: 'Upcoming', - description: 'Upcoming Badge shown in course outline when the course has not started yet.' + description: 'Upcoming Badge shown in course outline when the course has not started yet.', }, }); From d55150c7dadced75fa9fc5465f6d5f3cb51c56cc Mon Sep 17 00:00:00 2001 From: Navin Karkera Date: Fri, 12 Dec 2025 17:53:28 +0530 Subject: [PATCH 15/22] test: add tests --- src/course-outline/CourseOutline.test.tsx | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/src/course-outline/CourseOutline.test.tsx b/src/course-outline/CourseOutline.test.tsx index 78607712f8..4f20190443 100644 --- a/src/course-outline/CourseOutline.test.tsx +++ b/src/course-outline/CourseOutline.test.tsx @@ -1,4 +1,4 @@ -import { getConfig } from '@edx/frontend-platform'; +import { getConfig, setConfig } from '@edx/frontend-platform'; import { cloneDeep } from 'lodash'; import { closestCorners } from '@dnd-kit/core'; import { logError } from '@edx/frontend-platform/logging'; @@ -2484,4 +2484,16 @@ describe('', () => { }); expect(axiosMock.history.delete[0].url).toBe(getDownstreamApiUrl(courseSectionMock.id)); }); + + it('check that the new status bar and expand bar is shown when flag is set', async () => { + setConfig({ + ...getConfig(), + ENABLE_COURSE_OUTLINE_NEW_DESIGN: 'true', + }); + renderComponent(); + expect(await screen.findByRole('button', { name: 'Collapse all' })).toBeInTheDocument(); + expect(await screen.findByRole('link', { name: 'View live' })).toBeInTheDocument(); + expect(await screen.findByRole('button', { name: 'Add' })).toBeInTheDocument(); + expect(await screen.findByRole('button', { name: 'More actions' })).toBeInTheDocument(); + }); }); From 6064fd54be3cf96eda399df2a676f7405859bf72 Mon Sep 17 00:00:00 2001 From: Navin Karkera Date: Mon, 15 Dec 2025 16:52:51 +0530 Subject: [PATCH 16/22] feat: notification count from notification plugin in course outline --- .../status-bar/NotificationStatusIcon.tsx | 79 +++++++++++++++++++ src/course-outline/status-bar/StatusBar.tsx | 8 +- src/data/api.ts | 1 + src/stubs/empty-notifications-plugin.tsx | 11 +++ webpack.dev.config.js | 16 ++++ webpack.prod.config.js | 16 ++++ 6 files changed, 125 insertions(+), 6 deletions(-) create mode 100644 src/course-outline/status-bar/NotificationStatusIcon.tsx create mode 100644 src/stubs/empty-notifications-plugin.tsx diff --git a/src/course-outline/status-bar/NotificationStatusIcon.tsx b/src/course-outline/status-bar/NotificationStatusIcon.tsx new file mode 100644 index 0000000000..cb2037373f --- /dev/null +++ b/src/course-outline/status-bar/NotificationStatusIcon.tsx @@ -0,0 +1,79 @@ +import { FormattedMessage } from '@edx/frontend-platform/i18n'; +import { Icon } from '@openedx/paragon'; +import { NotificationsNone } from '@openedx/paragon/icons'; +import React from 'react'; +import messages from './messages'; + +interface HooKType { + notificationAppData: { + tabsCount?: { + count?: number; + } + } +} + +// Load the hook module asynchronously +function useDynamicHookShim() { + const [hook, setHook] = React.useState<(() => HooKType) | null>(null); + + React.useEffect(() => { + let cancelled = false; + + async function load() { + try { + const module = await import("@edx/frontend-plugin-notifications"); + const hookFn = module.useAppNotifications ?? module.default; + if (!cancelled) { + // `module.useAppNotifications` is itself a hook + setHook(() => hookFn); + } + } catch (err: any) { + // If the module cannot be found, just keep `hook` as null. + console.error("Failed to load notifications plugin:", err); + // No hook – the UI will fall back to the placeholder. + } + } + + load(); + + return () => { + cancelled = true; + }; + }, []); + + return hook; +} + +// Component that actually calls the loaded hook +function NotificationHookConsumer({ hook }: { hook: () => HooKType }) { + // The hook is now called on **every** render of this component + const { notificationAppData } = hook(); + + if (!notificationAppData?.tabsCount?.count || notificationAppData?.tabsCount?.count < 1) { + return null; + } + + // You can use `hookResult` here as needed + return ( + + + + + ); +} + +// Main component +export const NotificationStatusIcon = () => { + const loadedHook = useDynamicHookShim(); + + if (!loadedHook) { + return null; + } + + // Once loaded, delegate to a component that calls the hook + return ; +}; + diff --git a/src/course-outline/status-bar/StatusBar.tsx b/src/course-outline/status-bar/StatusBar.tsx index b9a5f26b8e..9a3494967d 100644 --- a/src/course-outline/status-bar/StatusBar.tsx +++ b/src/course-outline/status-bar/StatusBar.tsx @@ -8,6 +8,7 @@ import { CourseOutlineStatusBar } from '@src/course-outline/data/types'; import { ChecklistRtl, NotificationsNone } from '@openedx/paragon/icons'; import messages from './messages'; import { useWaffleFlags } from '../../data/apiHooks'; +import { NotificationStatusIcon } from './NotificationStatusIcon'; const CourseBadge = ({ startDate, endDate }: { startDate: Moment, endDate: Moment }) => { const now = moment().utc(); @@ -131,12 +132,7 @@ export const StatusBar = ({ startDateRaw={courseReleaseDate} datesLink={waffleFlags.useNewScheduleDetailsPage ? `/course/${courseId}/settings/details/#schedule` : scheduleDestination()} /> - {(notificationCount || 0) > 0 && ( - - - {intl.formatMessage(messages.notificationMetadataTitle, { count: notificationCount })} - - )} + { + return { + notificationAppData: { + tabsCount: { + count: 0, + } + } + }; +}; + +export const NotificationsTray = () => <>; diff --git a/webpack.dev.config.js b/webpack.dev.config.js index 3d0591d7d9..0c4757a470 100644 --- a/webpack.dev.config.js +++ b/webpack.dev.config.js @@ -1,5 +1,6 @@ const path = require('path'); const { createConfig } = require('@openedx/frontend-build'); +const webpack = require('webpack'); const config = createConfig('webpack-dev', { resolve: { @@ -14,6 +15,21 @@ const config = createConfig('webpack-dev', { constants: false, }, }, + // Silently ignore “module not found” errors for that exact specifier. + plugins: [ + new webpack.NormalModuleReplacementPlugin( + /@edx\/frontend-plugin-notifications/, + (resource) => { + try { + // Try to resolve the real package. If it exists, do nothing. + require.resolve('@edx/frontend-plugin-notifications'); + } catch (e) { + // Package not found → point to the stub we created. + resource.request = path.resolve(__dirname, 'src/stubs/empty-notifications-plugin.tsx'); + } + } + ), + ], }); module.exports = config; diff --git a/webpack.prod.config.js b/webpack.prod.config.js index 78bc2606c9..c4b65f3ff0 100644 --- a/webpack.prod.config.js +++ b/webpack.prod.config.js @@ -1,5 +1,6 @@ const path = require('path'); const { createConfig } = require('@openedx/frontend-build'); +const webpack = require('webpack'); const config = createConfig('webpack-prod', { resolve: { @@ -14,6 +15,21 @@ const config = createConfig('webpack-prod', { constants: false, }, }, + // Silently ignore “module not found” errors for that exact specifier. + plugins: [ + new webpack.NormalModuleReplacementPlugin( + /@edx\/frontend-plugin-notifications/, + (resource) => { + try { + // Try to resolve the real package. If it exists, do nothing. + require.resolve('@edx/frontend-plugin-notifications'); + } catch (e) { + // Package not found → point to the stub we created. + resource.request = path.resolve(__dirname, 'src/stubs/empty-notifications-plugin.tsx'); + } + } + ), + ], }); module.exports = config; From 2d08325526eb479e9517aa197bfe65070d171f06 Mon Sep 17 00:00:00 2001 From: Navin Karkera Date: Mon, 15 Dec 2025 17:04:19 +0530 Subject: [PATCH 17/22] fixup! feat: notification count from notification plugin in course outline --- src/course-outline/CourseOutline.tsx | 1 - .../status-bar/NotificationStatusIcon.tsx | 13 ++++++------- src/course-outline/status-bar/StatusBar.tsx | 4 +--- src/stubs/empty-notifications-plugin.tsx | 18 ++++++++---------- webpack.dev.config.js | 4 +++- webpack.prod.config.js | 4 +++- 6 files changed, 21 insertions(+), 23 deletions(-) diff --git a/src/course-outline/CourseOutline.tsx b/src/course-outline/CourseOutline.tsx index cacb2fe314..e629e289b6 100644 --- a/src/course-outline/CourseOutline.tsx +++ b/src/course-outline/CourseOutline.tsx @@ -344,7 +344,6 @@ const CourseOutline = () => { courseId={courseId} isLoading={isLoading} statusBarData={statusBarData} - notificationCount={3} /> ) : ( hookFn); } } catch (err: any) { - // If the module cannot be found, just keep `hook` as null. - console.error("Failed to load notifications plugin:", err); - // No hook – the UI will fall back to the placeholder. + // eslint-disable-next-line no-console + console.error('Failed to load notifications plugin:', err); } } @@ -45,7 +45,7 @@ function useDynamicHookShim() { } // Component that actually calls the loaded hook -function NotificationHookConsumer({ hook }: { hook: () => HooKType }) { +const NotificationHookConsumer = ({ hook }: { hook: () => HooKType }) => { // The hook is now called on **every** render of this component const { notificationAppData } = hook(); @@ -63,7 +63,7 @@ function NotificationHookConsumer({ hook }: { hook: () => HooKType }) { /> ); -} +}; // Main component export const NotificationStatusIcon = () => { @@ -76,4 +76,3 @@ export const NotificationStatusIcon = () => { // Once loaded, delegate to a component that calls the hook return ; }; - diff --git a/src/course-outline/status-bar/StatusBar.tsx b/src/course-outline/status-bar/StatusBar.tsx index 9a3494967d..9de2f87515 100644 --- a/src/course-outline/status-bar/StatusBar.tsx +++ b/src/course-outline/status-bar/StatusBar.tsx @@ -5,7 +5,7 @@ import { Badge, Icon, Stack } from '@openedx/paragon'; import { Link } from 'react-router-dom'; import { CourseOutlineStatusBar } from '@src/course-outline/data/types'; -import { ChecklistRtl, NotificationsNone } from '@openedx/paragon/icons'; +import { ChecklistRtl } from '@openedx/paragon/icons'; import messages from './messages'; import { useWaffleFlags } from '../../data/apiHooks'; import { NotificationStatusIcon } from './NotificationStatusIcon'; @@ -89,14 +89,12 @@ export interface StatusBarProps { courseId: string; isLoading: boolean; statusBarData: CourseOutlineStatusBar; - notificationCount?: number; } export const StatusBar = ({ statusBarData, isLoading, courseId, - notificationCount, }: StatusBarProps) => { const intl = useIntl(); const waffleFlags = useWaffleFlags(courseId); diff --git a/src/stubs/empty-notifications-plugin.tsx b/src/stubs/empty-notifications-plugin.tsx index da84980348..f7fc717d0b 100644 --- a/src/stubs/empty-notifications-plugin.tsx +++ b/src/stubs/empty-notifications-plugin.tsx @@ -1,11 +1,9 @@ -export const useAppNotifications = () => { - return { - notificationAppData: { - tabsCount: { - count: 0, - } - } - }; -}; +export const useAppNotifications = () => ({ + notificationAppData: { + tabsCount: { + count: 0, + }, + }, +}); -export const NotificationsTray = () => <>; +export const NotificationsTray = () => null; diff --git a/webpack.dev.config.js b/webpack.dev.config.js index 0c4757a470..34cc439e2a 100644 --- a/webpack.dev.config.js +++ b/webpack.dev.config.js @@ -1,5 +1,6 @@ const path = require('path'); const { createConfig } = require('@openedx/frontend-build'); +// eslint-disable-next-line import/no-extraneous-dependencies const webpack = require('webpack'); const config = createConfig('webpack-dev', { @@ -25,9 +26,10 @@ const config = createConfig('webpack-dev', { require.resolve('@edx/frontend-plugin-notifications'); } catch (e) { // Package not found → point to the stub we created. + // eslint-disable-next-line no-param-reassign resource.request = path.resolve(__dirname, 'src/stubs/empty-notifications-plugin.tsx'); } - } + }, ), ], }); diff --git a/webpack.prod.config.js b/webpack.prod.config.js index c4b65f3ff0..f1fec7d6b8 100644 --- a/webpack.prod.config.js +++ b/webpack.prod.config.js @@ -1,5 +1,6 @@ const path = require('path'); const { createConfig } = require('@openedx/frontend-build'); +// eslint-disable-next-line import/no-extraneous-dependencies const webpack = require('webpack'); const config = createConfig('webpack-prod', { @@ -25,9 +26,10 @@ const config = createConfig('webpack-prod', { require.resolve('@edx/frontend-plugin-notifications'); } catch (e) { // Package not found → point to the stub we created. + // eslint-disable-next-line no-param-reassign resource.request = path.resolve(__dirname, 'src/stubs/empty-notifications-plugin.tsx'); } - } + }, ), ], }); From bef98a0182c767f1bd95db00521c3ed2d6bef770 Mon Sep 17 00:00:00 2001 From: Navin Karkera Date: Mon, 15 Dec 2025 20:46:28 +0530 Subject: [PATCH 18/22] chore: use @src for imports --- .../status-bar/LegacyStatusBar.test.tsx | 2 +- src/course-outline/status-bar/LegacyStatusBar.tsx | 14 +++++++------- src/course-outline/status-bar/StatusBar.test.tsx | 8 ++++---- src/course-outline/status-bar/StatusBar.tsx | 2 +- 4 files changed, 13 insertions(+), 13 deletions(-) diff --git a/src/course-outline/status-bar/LegacyStatusBar.test.tsx b/src/course-outline/status-bar/LegacyStatusBar.test.tsx index ba5f4d1f4b..de3ca2f51a 100644 --- a/src/course-outline/status-bar/LegacyStatusBar.test.tsx +++ b/src/course-outline/status-bar/LegacyStatusBar.test.tsx @@ -5,11 +5,11 @@ import { initializeMockApp } from '@edx/frontend-platform'; import { getConfig, setConfig } from '@edx/frontend-platform/config'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { CourseOutlineStatusBar } from '@src/course-outline/data/types'; import messages from './messages'; import initializeStore from '../../store'; import { VIDEO_SHARING_OPTIONS } from '../constants'; import { LegacyStatusBar, LegacyStatusBarProps } from './LegacyStatusBar'; -import { CourseOutlineStatusBar } from '../data/types'; let store; const mockPathname = '/foo-bar'; diff --git a/src/course-outline/status-bar/LegacyStatusBar.tsx b/src/course-outline/status-bar/LegacyStatusBar.tsx index 05cce62609..72dd347c10 100644 --- a/src/course-outline/status-bar/LegacyStatusBar.tsx +++ b/src/course-outline/status-bar/LegacyStatusBar.tsx @@ -8,14 +8,14 @@ import { Link } from 'react-router-dom'; import { ReactNode } from 'react'; import { CourseOutlineStatusBar } from '@src/course-outline/data/types'; -import { ContentTagsDrawerSheet } from '../../content-tags-drawer'; -import TagCount from '../../generic/tag-count'; -import { useHelpUrls } from '../../help-urls/hooks'; -import { useWaffleFlags } from '../../data/apiHooks'; -import { VIDEO_SHARING_OPTIONS } from '../constants'; -import { useContentTagsCount } from '../../generic/data/apiHooks'; +import { ContentTagsDrawerSheet } from '@src/content-tags-drawer'; +import TagCount from '@src/generic/tag-count'; +import { useHelpUrls } from '@src/help-urls/hooks'; +import { useWaffleFlags } from '@src/data/apiHooks'; +import { VIDEO_SHARING_OPTIONS } from '@src/course-outline/constants'; +import { useContentTagsCount } from '@src/generic/data/apiHooks'; +import { getVideoSharingOptionText } from '@src/course-outline/utils'; import messages from './messages'; -import { getVideoSharingOptionText } from '../utils'; interface StatusBarItemProps { title: string, diff --git a/src/course-outline/status-bar/StatusBar.test.tsx b/src/course-outline/status-bar/StatusBar.test.tsx index ae6e648791..6aaff5bd38 100644 --- a/src/course-outline/status-bar/StatusBar.test.tsx +++ b/src/course-outline/status-bar/StatusBar.test.tsx @@ -1,8 +1,8 @@ -import messages from './messages'; -import { VIDEO_SHARING_OPTIONS } from '../constants'; -import { CourseOutlineStatusBar } from '../data/types'; +import { VIDEO_SHARING_OPTIONS } from '@src/course-outline/constants'; +import { CourseOutlineStatusBar } from '@src/course-outline/data/types'; +import { initializeMocks, render, screen } from '@src/testUtils'; import { StatusBar, StatusBarProps } from './StatusBar'; -import { initializeMocks, render, screen } from '../../testUtils'; +import messages from './messages'; const courseId = 'course-v1:123'; const isLoading = false; diff --git a/src/course-outline/status-bar/StatusBar.tsx b/src/course-outline/status-bar/StatusBar.tsx index 9de2f87515..b1fd74d5a6 100644 --- a/src/course-outline/status-bar/StatusBar.tsx +++ b/src/course-outline/status-bar/StatusBar.tsx @@ -6,8 +6,8 @@ import { Link } from 'react-router-dom'; import { CourseOutlineStatusBar } from '@src/course-outline/data/types'; import { ChecklistRtl } from '@openedx/paragon/icons'; +import { useWaffleFlags } from '@src/data/apiHooks'; import messages from './messages'; -import { useWaffleFlags } from '../../data/apiHooks'; import { NotificationStatusIcon } from './NotificationStatusIcon'; const CourseBadge = ({ startDate, endDate }: { startDate: Moment, endDate: Moment }) => { From 03869ab859876e37acdf2790e8a4d4998b7dbd47 Mon Sep 17 00:00:00 2001 From: Navin Karkera Date: Mon, 15 Dec 2025 21:15:08 +0530 Subject: [PATCH 19/22] chore: eslint issue --- src/course-outline/status-bar/NotificationStatusIcon.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/course-outline/status-bar/NotificationStatusIcon.tsx b/src/course-outline/status-bar/NotificationStatusIcon.tsx index 9355aa8f02..fe9eff315b 100644 --- a/src/course-outline/status-bar/NotificationStatusIcon.tsx +++ b/src/course-outline/status-bar/NotificationStatusIcon.tsx @@ -21,7 +21,7 @@ function useDynamicHookShim() { async function load() { try { - // eslint-disable-next-line import/no-extraneous-dependencies + // eslint-disable-next-line import/no-extraneous-dependencies, import/no-unresolved const module = await import('@edx/frontend-plugin-notifications'); const hookFn = module.useAppNotifications ?? module.default; if (!cancelled) { From c60dc8772ebdfd3e5873b713ca13f59f5205e8b2 Mon Sep 17 00:00:00 2001 From: Navin Karkera Date: Mon, 15 Dec 2025 22:09:23 +0530 Subject: [PATCH 20/22] chore: ts issue --- src/course-outline/status-bar/NotificationStatusIcon.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/course-outline/status-bar/NotificationStatusIcon.tsx b/src/course-outline/status-bar/NotificationStatusIcon.tsx index fe9eff315b..5de6064f28 100644 --- a/src/course-outline/status-bar/NotificationStatusIcon.tsx +++ b/src/course-outline/status-bar/NotificationStatusIcon.tsx @@ -21,6 +21,7 @@ function useDynamicHookShim() { async function load() { try { + // @ts-ignore // eslint-disable-next-line import/no-extraneous-dependencies, import/no-unresolved const module = await import('@edx/frontend-plugin-notifications'); const hookFn = module.useAppNotifications ?? module.default; From 33e562d36b8cde6d4b00a7b3d128658f76763575 Mon Sep 17 00:00:00 2001 From: Navin Karkera Date: Tue, 16 Dec 2025 12:19:42 +0530 Subject: [PATCH 21/22] test: notification badge --- src/course-outline/CourseOutline.test.tsx | 7 ++- .../NotificationStatusIcon.test.tsx | 42 +++++++++++++++++ .../status-bar/NotificationStatusIcon.tsx | 45 +------------------ src/course-outline/status-bar/hooks.ts | 43 ++++++++++++++++++ src/stubs/empty-notifications-plugin.tsx | 5 ++- 5 files changed, 97 insertions(+), 45 deletions(-) create mode 100644 src/course-outline/status-bar/NotificationStatusIcon.test.tsx create mode 100644 src/course-outline/status-bar/hooks.ts diff --git a/src/course-outline/CourseOutline.test.tsx b/src/course-outline/CourseOutline.test.tsx index 4f20190443..d821df9cd8 100644 --- a/src/course-outline/CourseOutline.test.tsx +++ b/src/course-outline/CourseOutline.test.tsx @@ -17,6 +17,7 @@ import { act, fireEvent, initializeMocks, render, screen, waitFor, within, } from '@src/testUtils'; import { XBlock } from '@src/data/types'; +import { userEvent } from '@testing-library/user-event'; import { getCourseBestPracticesApiUrl, getCourseLaunchApiUrl, @@ -2491,9 +2492,13 @@ describe('', () => { ENABLE_COURSE_OUTLINE_NEW_DESIGN: 'true', }); renderComponent(); - expect(await screen.findByRole('button', { name: 'Collapse all' })).toBeInTheDocument(); + const btn = await screen.findByRole('button', { name: 'Collapse all' }); + expect(btn).toBeInTheDocument(); expect(await screen.findByRole('link', { name: 'View live' })).toBeInTheDocument(); expect(await screen.findByRole('button', { name: 'Add' })).toBeInTheDocument(); expect(await screen.findByRole('button', { name: 'More actions' })).toBeInTheDocument(); + const user = userEvent.setup(); + await user.click(btn); + expect(await screen.findByRole('button', { name: 'Expand all' })).toBeInTheDocument(); }); }); diff --git a/src/course-outline/status-bar/NotificationStatusIcon.test.tsx b/src/course-outline/status-bar/NotificationStatusIcon.test.tsx new file mode 100644 index 0000000000..b7b478a426 --- /dev/null +++ b/src/course-outline/status-bar/NotificationStatusIcon.test.tsx @@ -0,0 +1,42 @@ +import { initializeMocks, render, screen } from '@src/testUtils'; +import { NotificationStatusIcon } from './NotificationStatusIcon'; + +let mockCount = 0; + +jest.mock('./hooks.ts', () => ({ + useDynamicHookShim: () => () => ({ + notificationAppData: { + tabsCount: { + count: mockCount, + }, + }, + }), +})); + +const renderComponent = () => render( + , +); + +describe('NotificationStatusIcon', () => { + beforeEach(() => { + initializeMocks(); + }); + + test('should display a status icon', async () => { + mockCount = 2; + renderComponent(); + expect(await screen.findByText('2 notifications')).toBeInTheDocument(); + }); + + test('check 1 notification text', async () => { + mockCount = 1; + renderComponent(); + expect(await screen.findByText('1 notification')).toBeInTheDocument(); + }); + + test('should not display a status icon if 0 notifications', async () => { + mockCount = 0; + renderComponent(); + expect(await screen.findByTestId('redux-provider')).toBeEmptyDOMElement(); + }); +}); diff --git a/src/course-outline/status-bar/NotificationStatusIcon.tsx b/src/course-outline/status-bar/NotificationStatusIcon.tsx index 5de6064f28..d7a926077c 100644 --- a/src/course-outline/status-bar/NotificationStatusIcon.tsx +++ b/src/course-outline/status-bar/NotificationStatusIcon.tsx @@ -1,50 +1,9 @@ import { FormattedMessage } from '@edx/frontend-platform/i18n'; import { Icon } from '@openedx/paragon'; import { NotificationsNone } from '@openedx/paragon/icons'; -import React from 'react'; +import { HooKType, useDynamicHookShim } from './hooks'; import messages from './messages'; -interface HooKType { - notificationAppData: { - tabsCount?: { - count?: number; - } - } -} - -// Load the hook module asynchronously -function useDynamicHookShim() { - const [hook, setHook] = React.useState<(() => HooKType) | null>(null); - - React.useEffect(() => { - let cancelled = false; - - async function load() { - try { - // @ts-ignore - // eslint-disable-next-line import/no-extraneous-dependencies, import/no-unresolved - const module = await import('@edx/frontend-plugin-notifications'); - const hookFn = module.useAppNotifications ?? module.default; - if (!cancelled) { - // `module.useAppNotifications` is itself a hook - setHook(() => hookFn); - } - } catch (err: any) { - // eslint-disable-next-line no-console - console.error('Failed to load notifications plugin:', err); - } - } - - load(); - - return () => { - cancelled = true; - }; - }, []); - - return hook; -} - // Component that actually calls the loaded hook const NotificationHookConsumer = ({ hook }: { hook: () => HooKType }) => { // The hook is now called on **every** render of this component @@ -54,7 +13,6 @@ const NotificationHookConsumer = ({ hook }: { hook: () => HooKType }) => { return null; } - // You can use `hookResult` here as needed return ( @@ -70,6 +28,7 @@ const NotificationHookConsumer = ({ hook }: { hook: () => HooKType }) => { export const NotificationStatusIcon = () => { const loadedHook = useDynamicHookShim(); + // istanbul ignore if if (!loadedHook) { return null; } diff --git a/src/course-outline/status-bar/hooks.ts b/src/course-outline/status-bar/hooks.ts new file mode 100644 index 0000000000..de09429806 --- /dev/null +++ b/src/course-outline/status-bar/hooks.ts @@ -0,0 +1,43 @@ +/* istanbul ignore file */ +import React from 'react'; + +export interface HooKType { + notificationAppData: { + tabsCount?: { + count?: number; + } + } +} + +// Load the hook module asynchronously +export function useDynamicHookShim() { + const [hook, setHook] = React.useState<(() => HooKType) | null>(null); + + React.useEffect(() => { + let cancelled = false; + + async function load() { + try { + // @ts-ignore + // eslint-disable-next-line import/no-extraneous-dependencies, import/no-unresolved + const module = await import('@edx/frontend-plugin-notifications'); + const hookFn = module.useAppNotifications ?? module.default; + if (!cancelled) { + // `module.useAppNotifications` is itself a hook + setHook(() => hookFn); + } + } catch (err: any) { + // eslint-disable-next-line no-console + console.error('Failed to load notifications plugin:', err); + } + } + + load(); + + return () => { + cancelled = true; + }; + }, []); + + return hook; +} diff --git a/src/stubs/empty-notifications-plugin.tsx b/src/stubs/empty-notifications-plugin.tsx index f7fc717d0b..f56f3706ef 100644 --- a/src/stubs/empty-notifications-plugin.tsx +++ b/src/stubs/empty-notifications-plugin.tsx @@ -1,3 +1,4 @@ +/* istanbul ignore file */ export const useAppNotifications = () => ({ notificationAppData: { tabsCount: { @@ -6,4 +7,6 @@ export const useAppNotifications = () => ({ }, }); -export const NotificationsTray = () => null; +export const NotificationsTray: React.FC = () => null; + +export default NotificationsTray; From ed0e2e94c6875ef7b90346728578ed782ec54426 Mon Sep 17 00:00:00 2001 From: Navin Karkera Date: Wed, 17 Dec 2025 11:29:19 +0530 Subject: [PATCH 22/22] chore: update imports and fix typos --- src/course-outline/status-bar/LegacyStatusBar.test.tsx | 8 ++++---- src/course-outline/status-bar/NotificationStatusIcon.tsx | 4 ++-- src/course-outline/status-bar/hooks.ts | 4 ++-- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/course-outline/status-bar/LegacyStatusBar.test.tsx b/src/course-outline/status-bar/LegacyStatusBar.test.tsx index de3ca2f51a..e6669d7f31 100644 --- a/src/course-outline/status-bar/LegacyStatusBar.test.tsx +++ b/src/course-outline/status-bar/LegacyStatusBar.test.tsx @@ -6,8 +6,8 @@ import { getConfig, setConfig } from '@edx/frontend-platform/config'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { CourseOutlineStatusBar } from '@src/course-outline/data/types'; +import initializeStore from '@src/store'; import messages from './messages'; -import initializeStore from '../../store'; import { VIDEO_SHARING_OPTIONS } from '../constants'; import { LegacyStatusBar, LegacyStatusBarProps } from './LegacyStatusBar'; @@ -25,12 +25,12 @@ jest.mock('react-router-dom', () => ({ }), })); -jest.mock('../../generic/data/api', () => ({ - ...jest.requireActual('../../generic/data/api'), +jest.mock('@src/generic/data/api', () => ({ + ...jest.requireActual('@src/generic/data/api'), getTagsCount: jest.fn().mockResolvedValue({ 'course-v1:123': 17 }), })); -jest.mock('../../help-urls/hooks', () => ({ +jest.mock('@src/help-urls/hooks', () => ({ useHelpUrls: () => ({ contentHighlights: 'content-highlights-link', socialSharing: 'social-sharing-link', diff --git a/src/course-outline/status-bar/NotificationStatusIcon.tsx b/src/course-outline/status-bar/NotificationStatusIcon.tsx index d7a926077c..e988c31b1f 100644 --- a/src/course-outline/status-bar/NotificationStatusIcon.tsx +++ b/src/course-outline/status-bar/NotificationStatusIcon.tsx @@ -1,11 +1,11 @@ import { FormattedMessage } from '@edx/frontend-platform/i18n'; import { Icon } from '@openedx/paragon'; import { NotificationsNone } from '@openedx/paragon/icons'; -import { HooKType, useDynamicHookShim } from './hooks'; +import { HookType, useDynamicHookShim } from './hooks'; import messages from './messages'; // Component that actually calls the loaded hook -const NotificationHookConsumer = ({ hook }: { hook: () => HooKType }) => { +const NotificationHookConsumer = ({ hook }: { hook: () => HookType }) => { // The hook is now called on **every** render of this component const { notificationAppData } = hook(); diff --git a/src/course-outline/status-bar/hooks.ts b/src/course-outline/status-bar/hooks.ts index de09429806..9ea6ac0e87 100644 --- a/src/course-outline/status-bar/hooks.ts +++ b/src/course-outline/status-bar/hooks.ts @@ -1,7 +1,7 @@ /* istanbul ignore file */ import React from 'react'; -export interface HooKType { +export interface HookType { notificationAppData: { tabsCount?: { count?: number; @@ -11,7 +11,7 @@ export interface HooKType { // Load the hook module asynchronously export function useDynamicHookShim() { - const [hook, setHook] = React.useState<(() => HooKType) | null>(null); + const [hook, setHook] = React.useState<(() => HookType) | null>(null); React.useEffect(() => { let cancelled = false;