feat: new course outline header [FC-0114] (#2735)

Adds new header and subheader to course outline. Converts existing js code to ts.
This commit is contained in:
Navin Karkera
2025-12-17 23:43:19 +05:30
committed by GitHub
parent 6f37118960
commit ae67be83a0
34 changed files with 990 additions and 403 deletions

1
.env
View File

@@ -37,6 +37,7 @@ ENABLE_VIDEO_UPLOAD_PAGE_LINK_IN_CONTENT_DROPDOWN=false
ENABLE_TAGGING_TAXONOMY_PAGES=true ENABLE_TAGGING_TAXONOMY_PAGES=true
ENABLE_CERTIFICATE_PAGE=true ENABLE_CERTIFICATE_PAGE=true
ENABLE_COURSE_IMPORT_IN_LIBRARY=false ENABLE_COURSE_IMPORT_IN_LIBRARY=false
ENABLE_COURSE_OUTLINE_NEW_DESIGN=false
BBB_LEARN_MORE_URL='' BBB_LEARN_MORE_URL=''
HOTJAR_APP_ID='' HOTJAR_APP_ID=''
HOTJAR_VERSION=6 HOTJAR_VERSION=6

View File

@@ -38,6 +38,7 @@ ENABLE_ASSETS_PAGE=false
ENABLE_VIDEO_UPLOAD_PAGE_LINK_IN_CONTENT_DROPDOWN=true ENABLE_VIDEO_UPLOAD_PAGE_LINK_IN_CONTENT_DROPDOWN=true
ENABLE_CERTIFICATE_PAGE=true ENABLE_CERTIFICATE_PAGE=true
ENABLE_COURSE_IMPORT_IN_LIBRARY=true ENABLE_COURSE_IMPORT_IN_LIBRARY=true
ENABLE_COURSE_OUTLINE_NEW_DESIGN=true
ENABLE_NEW_VIDEO_UPLOAD_PAGE=true ENABLE_NEW_VIDEO_UPLOAD_PAGE=true
ENABLE_TAGGING_TAXONOMY_PAGES=true ENABLE_TAGGING_TAXONOMY_PAGES=true
BBB_LEARN_MORE_URL='' BBB_LEARN_MORE_URL=''

View File

@@ -34,6 +34,7 @@ ENABLE_ASSETS_PAGE=false
ENABLE_VIDEO_UPLOAD_PAGE_LINK_IN_CONTENT_DROPDOWN=true ENABLE_VIDEO_UPLOAD_PAGE_LINK_IN_CONTENT_DROPDOWN=true
ENABLE_CERTIFICATE_PAGE=true ENABLE_CERTIFICATE_PAGE=true
ENABLE_COURSE_IMPORT_IN_LIBRARY=true ENABLE_COURSE_IMPORT_IN_LIBRARY=true
ENABLE_COURSE_OUTLINE_NEW_DESIGN=false
ENABLE_TAGGING_TAXONOMY_PAGES=true ENABLE_TAGGING_TAXONOMY_PAGES=true
BBB_LEARN_MORE_URL='' BBB_LEARN_MORE_URL=''
INVITE_STUDENTS_EMAIL_TO="someone@domain.com" INVITE_STUDENTS_EMAIL_TO="someone@domain.com"

View File

@@ -198,7 +198,7 @@ export const CourseLibraries = () => {
<SubHeader <SubHeader
title={intl.formatMessage(messages.headingTitle)} title={intl.formatMessage(messages.headingTitle)}
subtitle={intl.formatMessage(messages.headingSubtitle)} subtitle={intl.formatMessage(messages.headingSubtitle)}
headerActions={!showReviewAlert && outOfSyncCount > 0 && tabKey === CourseLibraryTabs.all && ( headerActions={(!showReviewAlert && outOfSyncCount > 0 && tabKey === CourseLibraryTabs.all) ? (
<Button <Button
variant="primary" variant="primary"
onClick={onAlertReview} onClick={onAlertReview}
@@ -206,7 +206,7 @@ export const CourseLibraries = () => {
> >
{intl.formatMessage(messages.reviewUpdatesBtn)} {intl.formatMessage(messages.reviewUpdatesBtn)}
</Button> </Button>
)} ) : null}
hideBorder hideBorder
/> />
<section className="mb-4"> <section className="mb-4">

View File

@@ -1,4 +1,4 @@
import { getConfig } from '@edx/frontend-platform'; import { getConfig, setConfig } from '@edx/frontend-platform';
import { cloneDeep } from 'lodash'; import { cloneDeep } from 'lodash';
import { closestCorners } from '@dnd-kit/core'; import { closestCorners } from '@dnd-kit/core';
import { logError } from '@edx/frontend-platform/logging'; import { logError } from '@edx/frontend-platform/logging';
@@ -17,6 +17,7 @@ import {
act, fireEvent, initializeMocks, render, screen, waitFor, within, act, fireEvent, initializeMocks, render, screen, waitFor, within,
} from '@src/testUtils'; } from '@src/testUtils';
import { XBlock } from '@src/data/types'; import { XBlock } from '@src/data/types';
import { userEvent } from '@testing-library/user-event';
import { import {
getCourseBestPracticesApiUrl, getCourseBestPracticesApiUrl,
getCourseLaunchApiUrl, getCourseLaunchApiUrl,
@@ -182,12 +183,10 @@ describe('<CourseOutline />', () => {
}); });
it('render CourseOutline component correctly', async () => { it('render CourseOutline component correctly', async () => {
const { getByText } = renderComponent(); renderComponent();
await waitFor(() => { expect(await screen.findByText('Demonstration Course')).toBeInTheDocument();
expect(getByText(messages.headingTitle.defaultMessage)).toBeInTheDocument(); expect(await screen.findByText(messages.headingSubtitle.defaultMessage)).toBeInTheDocument();
expect(getByText(messages.headingSubtitle.defaultMessage)).toBeInTheDocument();
});
}); });
it('logs an error when syncDiscussionsTopics encounters an API failure', async () => { it('logs an error when syncDiscussionsTopics encounters an API failure', async () => {
@@ -2486,4 +2485,20 @@ describe('<CourseOutline />', () => {
}); });
expect(axiosMock.history.delete[0].url).toBe(getDownstreamApiUrl(courseSectionMock.id)); 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();
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();
});
}); });

View File

@@ -1,5 +1,6 @@
import { useState, useEffect, useCallback } from 'react'; import { useState, useEffect, useCallback } from 'react';
import { useIntl } from '@edx/frontend-platform/i18n'; import { useIntl } from '@edx/frontend-platform/i18n';
import { getConfig } from '@edx/frontend-platform';
import { import {
Container, Container,
Layout, Layout,
@@ -7,9 +8,11 @@ import {
TransitionReplace, TransitionReplace,
Toast, Toast,
StandardModal, StandardModal,
Button,
ActionRow,
} from '@openedx/paragon'; } from '@openedx/paragon';
import { Helmet } from 'react-helmet'; 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 { useSelector } from 'react-redux';
import { import {
arrayMove, arrayMove,
@@ -44,7 +47,6 @@ import {
getTimedExamsFlag, getTimedExamsFlag,
} from './data/selectors'; } from './data/selectors';
import { COURSE_BLOCK_NAMES } from './constants'; import { COURSE_BLOCK_NAMES } from './constants';
import StatusBar from './status-bar/StatusBar';
import EnableHighlightsModal from './enable-highlights-modal/EnableHighlightsModal'; import EnableHighlightsModal from './enable-highlights-modal/EnableHighlightsModal';
import SectionCard from './section-card/SectionCard'; import SectionCard from './section-card/SectionCard';
import SubsectionCard from './subsection-card/SubsectionCard'; import SubsectionCard from './subsection-card/SubsectionCard';
@@ -61,8 +63,11 @@ import {
} from './drag-helper/utils'; } from './drag-helper/utils';
import { useCourseOutline } from './hooks'; import { useCourseOutline } from './hooks';
import messages from './messages'; import messages from './messages';
import headerMessages from './header-navigations/messages';
import { getTagsExportFile } from './data/api'; import { getTagsExportFile } from './data/api';
import OutlineAddChildButtons from './OutlineAddChildButtons'; import OutlineAddChildButtons from './OutlineAddChildButtons';
import { StatusBar } from './status-bar/StatusBar';
import { LegacyStatusBar } from './status-bar/LegacyStatusBar';
const CourseOutline = () => { const CourseOutline = () => {
const intl = useIntl(); const intl = useIntl();
@@ -141,6 +146,9 @@ const CourseOutline = () => {
resetScrollState, resetScrollState,
} = useCourseOutline({ courseId }); } = 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. // Use `setToastMessage` to show the toast.
const [toastMessage, setToastMessage] = useState<string | null>(null); const [toastMessage, setToastMessage] = useState<string | null>(null);
@@ -314,8 +322,9 @@ const CourseOutline = () => {
) : null} ) : null}
</TransitionReplace> </TransitionReplace>
<SubHeader <SubHeader
title={intl.formatMessage(messages.headingTitle)} title={courseName}
subtitle={intl.formatMessage(messages.headingSubtitle)} subtitle={intl.formatMessage(messages.headingSubtitle)}
hideBorder
headerActions={( headerActions={(
<CourseOutlineHeaderActionsSlot <CourseOutlineHeaderActionsSlot
isReIndexShow={isReIndexShow} isReIndexShow={isReIndexShow}
@@ -329,6 +338,23 @@ const CourseOutline = () => {
/> />
)} )}
/> />
{showNewActionsBar
? (
<StatusBar
courseId={courseId}
isLoading={isLoading}
statusBarData={statusBarData}
/>
) : (
<LegacyStatusBar
courseId={courseId}
isLoading={isLoading}
statusBarData={statusBarData}
openEnableHighlightsModal={openEnableHighlightsModal}
handleVideoSharingOptionChange={handleVideoSharingOptionChange}
/>
)}
<hr className="mt-4 mb-0 w-100 text-light-400" />
<Layout <Layout
lg={[{ span: 9 }, { span: 3 }]} lg={[{ span: 9 }, { span: 3 }]}
md={[{ span: 9 }, { span: 3 }]} md={[{ span: 9 }, { span: 3 }]}
@@ -339,14 +365,24 @@ const CourseOutline = () => {
<Layout.Element> <Layout.Element>
<article> <article>
<div> <div>
{showNewActionsBar && (
<ActionRow className="mt-3">
{Boolean(sectionsList.length) && (
<Button
variant="outline-primary"
id="expand-collapse-all-button"
data-testid="expand-collapse-all-button"
iconBefore={isSectionsExpanded ? CloseFullscreen : OpenInFull}
onClick={headerNavigationsActions.handleExpandAll}
>
{isSectionsExpanded
? intl.formatMessage(headerMessages.collapseAllButton)
: intl.formatMessage(headerMessages.expandAllButton)}
</Button>
)}
</ActionRow>
)}
<section className="course-outline-section"> <section className="course-outline-section">
<StatusBar
courseId={courseId}
isLoading={isLoading}
statusBarData={statusBarData}
openEnableHighlightsModal={openEnableHighlightsModal}
handleVideoSharingOptionChange={handleVideoSharingOptionChange}
/>
{!errors?.outlineIndexApi && ( {!errors?.outlineIndexApi && (
<div className="pt-4"> <div className="pt-4">
{sections.length ? ( {sections.length ? (

View File

@@ -22,6 +22,7 @@ const initialState = {
savingStatus: '', savingStatus: '',
statusBarData: { statusBarData: {
courseReleaseDate: '', courseReleaseDate: '',
endDate: '',
highlightsEnabledForMessaging: false, highlightsEnabledForMessaging: false,
isSelfPaced: false, isSelfPaced: false,
checklist: { checklist: {

View File

@@ -75,6 +75,7 @@ export function fetchCourseOutlineIndexQuery(courseId: string): (dispatch: any)
videoSharingEnabled, videoSharingEnabled,
videoSharingOptions, videoSharingOptions,
actions, actions,
end,
}, },
} = outlineIndex; } = outlineIndex;
dispatch(fetchOutlineIndexSuccess(outlineIndex)); dispatch(fetchOutlineIndexSuccess(outlineIndex));
@@ -83,6 +84,7 @@ export function fetchCourseOutlineIndexQuery(courseId: string): (dispatch: any)
highlightsEnabledForMessaging, highlightsEnabledForMessaging,
videoSharingOptions, videoSharingOptions,
videoSharingEnabled, videoSharingEnabled,
endDate: end,
})); }));
dispatch(updateCourseActions(actions)); dispatch(updateCourseActions(actions));

View File

@@ -4,6 +4,8 @@ export interface CourseStructure {
highlightsEnabledForMessaging: boolean, highlightsEnabledForMessaging: boolean,
videoSharingEnabled: boolean, videoSharingEnabled: boolean,
videoSharingOptions: string, videoSharingOptions: string,
start: string,
end: string,
actions: XBlockActions, actions: XBlockActions,
} }
@@ -33,6 +35,21 @@ export interface CourseDetails {
description?: string; description?: string;
} }
export interface CourseOutlineStatusBar {
courseReleaseDate: string;
endDate: string;
highlightsEnabledForMessaging: boolean;
isSelfPaced: boolean;
checklist: {
totalCourseLaunchChecks: number;
completedCourseLaunchChecks: number;
totalCourseBestPracticesChecks: number;
completedCourseBestPracticesChecks: number;
};
videoSharingEnabled: boolean;
videoSharingOptions: string;
}
export interface CourseOutlineState { export interface CourseOutlineState {
loadingStatus: { loadingStatus: {
outlineIndexLoadingStatus: string; outlineIndexLoadingStatus: string;
@@ -48,19 +65,7 @@ export interface CourseOutlineState {
}; };
outlineIndexData: object; outlineIndexData: object;
savingStatus: string; savingStatus: string;
statusBarData: { statusBarData: CourseOutlineStatusBar;
courseReleaseDate: string;
highlightsEnabledForMessaging: boolean;
isSelfPaced: boolean;
checklist: {
totalCourseLaunchChecks: number;
completedCourseLaunchChecks: number;
totalCourseBestPracticesChecks: number;
completedCourseBestPracticesChecks: number;
};
videoSharingEnabled: boolean;
videoSharingOptions: string;
};
sectionsList: Array<XBlock>; sectionsList: Array<XBlock>;
isCustomRelativeDatesActive: boolean; isCustomRelativeDatesActive: boolean;
currentSection: XBlock | {}; currentSection: XBlock | {};

View File

@@ -0,0 +1,58 @@
import {
fireEvent, initializeMocks, render, screen,
} 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<HeaderActionsProps>) => render(
<HeaderActions
actions={headerNavigationsActions}
courseActions={courseActions}
{...props}
/>,
);
describe('<HeaderActions />', () => {
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();
});
});

View File

@@ -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 (
<Stack direction="horizontal" gap={3}>
{courseActions.childAddable && (
<OverlayTrigger
placement="bottom"
overlay={(
<Tooltip id={intl.formatMessage(messages.newSectionButtonTooltip)}>
{intl.formatMessage(messages.newSectionButtonTooltip)}
</Tooltip>
)}
>
<Button
iconBefore={IconAdd}
onClick={handleNewSection}
disabled={!(errors?.outlineIndexApi === undefined || errors?.outlineIndexApi === null)}
variant="outline-primary"
>
{intl.formatMessage(messages.addButton)}
</Button>
</OverlayTrigger>
)}
<OverlayTrigger
placement="bottom"
overlay={(
<Tooltip id={intl.formatMessage(messages.viewLiveButtonTooltip)}>
{intl.formatMessage(messages.viewLiveButtonTooltip)}
</Tooltip>
)}
>
<Button
iconBefore={FindInPage}
href={lmsLink}
target="_blank"
variant="outline-primary"
>
{intl.formatMessage(messages.viewLiveButton)}
</Button>
</OverlayTrigger>
<Dropdown>
<Dropdown.Toggle
id="dropdown-toggle-with-iconbutton"
as={Button}
variant="outline-primary"
aria-label={intl.formatMessage(messages.moreActionsButtonAriaLabel)}
>
<Icon src={ViewSidebar} />
</Dropdown.Toggle>
<Dropdown.Menu className="mt-1">
<Dropdown.Item>
<Stack direction="horizontal" gap={2}>
<Icon src={InfoOutline} />
{intl.formatMessage(messages.infoButton)}
</Stack>
</Dropdown.Item>
<Dropdown.Item>
<Stack direction="horizontal" gap={2}>
<Icon src={AutoGraph} />
{intl.formatMessage(messages.analyticsButton)}
</Stack>
</Dropdown.Item>
<Dropdown.Item>
<Stack direction="horizontal" gap={2}>
<Icon src={HelpOutline} />
{intl.formatMessage(messages.helpButton)}
</Stack>
</Dropdown.Item>
</Dropdown.Menu>
</Dropdown>
</Stack>
);
};
export default HeaderActions;

View File

@@ -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(
<IntlProvider locale="en">
<HeaderNavigations
headerNavigationsActions={headerNavigationsActions}
isSectionsExpanded={false}
isDisabledReindexButton={false}
isReIndexShow
hasSections
courseActions={courseActions}
{...props}
/>
</IntlProvider>,
);
describe('<HeaderNavigations />', () => {
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();
});
});

View File

@@ -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<HeaderNavigationsProps>) => render(
<HeaderNavigations
headerNavigationsActions={headerNavigationsActions}
isSectionsExpanded={false}
isDisabledReindexButton={false}
isReIndexShow
hasSections
courseActions={courseActions}
{...props}
/>,
);
describe('<HeaderNavigations />', () => {
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();
});
});

View File

@@ -1,5 +1,4 @@
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types';
import { useIntl } from '@edx/frontend-platform/i18n'; import { useIntl } from '@edx/frontend-platform/i18n';
import { Button, OverlayTrigger, Tooltip } from '@openedx/paragon'; import { Button, OverlayTrigger, Tooltip } from '@openedx/paragon';
import { import {
@@ -8,8 +7,24 @@ import {
ArrowDropUp as ArrowUpIcon, ArrowDropUp as ArrowUpIcon,
} from '@openedx/paragon/icons'; } from '@openedx/paragon/icons';
import { OutlinePageErrors, XBlockActions } from '@src/data/types';
import messages from './messages'; 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 = ({ const HeaderNavigations = ({
headerNavigationsActions, headerNavigationsActions,
isReIndexShow, isReIndexShow,
@@ -18,7 +33,7 @@ const HeaderNavigations = ({
hasSections, hasSections,
courseActions, courseActions,
errors, errors,
}) => { }: HeaderNavigationsProps) => {
const intl = useIntl(); const intl = useIntl();
const { const {
handleNewSection, handleReIndex, handleExpandAll, lmsLink, handleNewSection, handleReIndex, handleExpandAll, lmsLink,
@@ -38,7 +53,7 @@ const HeaderNavigations = ({
<Button <Button
iconBefore={IconAdd} iconBefore={IconAdd}
onClick={handleNewSection} onClick={handleNewSection}
disabled={errors?.outlineIndexApi} disabled={!(errors?.outlineIndexApi === undefined || errors?.outlineIndexApi === null)}
> >
{intl.formatMessage(messages.newSectionButton)} {intl.formatMessage(messages.newSectionButton)}
</Button> </Button>
@@ -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; export default HeaderNavigations;

View File

@@ -5,6 +5,26 @@ const messages = defineMessages({
id: 'course-authoring.course-outline.header-navigations.button.new-section', id: 'course-authoring.course-outline.header-navigations.button.new-section',
defaultMessage: 'New section', defaultMessage: 'New section',
}, },
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: { newSectionButtonTooltip: {
id: 'course-authoring.course-outline.header-navigations.button.new-section.tooltip', id: 'course-authoring.course-outline.header-navigations.button.new-section.tooltip',
defaultMessage: 'Click to add a new section', defaultMessage: 'Click to add a new section',
@@ -29,6 +49,11 @@ const messages = defineMessages({
id: 'course-authoring.course-outline.header-navigations.button.view-live', id: 'course-authoring.course-outline.header-navigations.button.view-live',
defaultMessage: '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: { viewLiveButtonTooltip: {
id: 'course-authoring.course-outline.header-navigations.button.view-live.tooltip', id: 'course-authoring.course-outline.header-navigations.button.view-live.tooltip',
defaultMessage: 'Click to open the courseware in the LMS in a new tab', defaultMessage: 'Click to open the courseware in the LMS in a new tab',

View File

@@ -7,7 +7,8 @@ const messages = defineMessages({
}, },
headingSubtitle: { headingSubtitle: {
id: 'course-authoring.course-outline.subTitle', id: 'course-authoring.course-outline.subTitle',
defaultMessage: 'Content', defaultMessage: 'Course Outline',
description: 'Course Outline heading subTitle.',
}, },
alertSuccessTitle: { alertSuccessTitle: {
id: 'course-authoring.course-outline.reindex.alert.success.title', id: 'course-authoring.course-outline.reindex.alert.success.title',

View File

@@ -1,4 +1,3 @@
import React from 'react';
import { render, fireEvent } from '@testing-library/react'; import { render, fireEvent } from '@testing-library/react';
import { IntlProvider } from '@edx/frontend-platform/i18n'; import { IntlProvider } from '@edx/frontend-platform/i18n';
import { AppProvider } from '@edx/frontend-platform/react'; 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 { getConfig, setConfig } from '@edx/frontend-platform/config';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import StatusBar from './StatusBar'; import { CourseOutlineStatusBar } from '@src/course-outline/data/types';
import initializeStore from '@src/store';
import messages from './messages'; import messages from './messages';
import initializeStore from '../../store';
import { VIDEO_SHARING_OPTIONS } from '../constants'; import { VIDEO_SHARING_OPTIONS } from '../constants';
import { LegacyStatusBar, LegacyStatusBarProps } from './LegacyStatusBar';
let store; let store;
const mockPathname = '/foo-bar'; const mockPathname = '/foo-bar';
@@ -25,21 +25,22 @@ jest.mock('react-router-dom', () => ({
}), }),
})); }));
jest.mock('../../generic/data/api', () => ({ jest.mock('@src/generic/data/api', () => ({
...jest.requireActual('../../generic/data/api'), ...jest.requireActual('@src/generic/data/api'),
getTagsCount: jest.fn().mockResolvedValue({ 'course-v1:123': 17 }), getTagsCount: jest.fn().mockResolvedValue({ 'course-v1:123': 17 }),
})); }));
jest.mock('../../help-urls/hooks', () => ({ jest.mock('@src/help-urls/hooks', () => ({
useHelpUrls: () => ({ useHelpUrls: () => ({
contentHighlights: 'content-highlights-link', contentHighlights: 'content-highlights-link',
socialSharing: 'social-sharing-link', socialSharing: 'social-sharing-link',
}), }),
})); }));
const statusBarData = { const statusBarData: CourseOutlineStatusBar = {
courseReleaseDate: 'Feb 05, 2013 at 05:00 UTC', courseReleaseDate: 'Feb 05, 2013 at 05:00 UTC',
isSelfPaced: true, isSelfPaced: true,
endDate: 'Feb 05, 2014 at 05:00 UTC',
checklist: { checklist: {
totalCourseLaunchChecks: 5, totalCourseLaunchChecks: 5,
completedCourseLaunchChecks: 1, completedCourseLaunchChecks: 1,
@@ -47,18 +48,17 @@ const statusBarData = {
completedCourseBestPracticesChecks: 1, completedCourseBestPracticesChecks: 1,
}, },
highlightsEnabledForMessaging: true, highlightsEnabledForMessaging: true,
highlightsDocUrl: 'https://example.com/highlights-doc',
videoSharingEnabled: true, videoSharingEnabled: true,
videoSharingOptions: VIDEO_SHARING_OPTIONS.allOn, videoSharingOptions: VIDEO_SHARING_OPTIONS.allOn,
}; };
const queryClient = new QueryClient(); const queryClient = new QueryClient();
const renderComponent = (props) => render( const renderComponent = (props?: Partial<LegacyStatusBarProps>) => render(
<AppProvider store={store} messages={{}}> <AppProvider store={store} messages={{}}>
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<IntlProvider locale="en"> <IntlProvider locale="en">
<StatusBar <LegacyStatusBar
courseId={courseId} courseId={courseId}
isLoading={isLoading} isLoading={isLoading}
openEnableHighlightsModal={openEnableHighlightsModalMock} openEnableHighlightsModal={openEnableHighlightsModalMock}
@@ -71,7 +71,7 @@ const renderComponent = (props) => render(
</AppProvider>, </AppProvider>,
); );
describe('<StatusBar />', () => { describe('<LegacyStatusBar />', () => {
beforeEach(() => { beforeEach(() => {
initializeMockApp({ initializeMockApp({
authenticatedUser: { authenticatedUser: {
@@ -84,7 +84,7 @@ describe('<StatusBar />', () => {
store = initializeStore(); store = initializeStore();
}); });
it('renders StatusBar component correctly', () => { it('renders LegacyStatusBar component correctly', () => {
const { getByText } = renderComponent(); const { getByText } = renderComponent();
expect(getByText(messages.startDateTitle.defaultMessage)).toBeInTheDocument(); expect(getByText(messages.startDateTitle.defaultMessage)).toBeInTheDocument();
@@ -102,7 +102,7 @@ describe('<StatusBar />', () => {
expect(getByText(messages.videoSharingTitle.defaultMessage)).toBeInTheDocument(); expect(getByText(messages.videoSharingTitle.defaultMessage)).toBeInTheDocument();
}); });
it('renders StatusBar when isSelfPaced is false', () => { it('renders LegacyStatusBar when isSelfPaced is false', () => {
const { getByText } = renderComponent({ const { getByText } = renderComponent({
statusBarData: { statusBarData: {
...statusBarData, ...statusBarData,

View File

@@ -1,24 +1,28 @@
import { useContext } from 'react';
import moment from 'moment/moment'; import moment from 'moment/moment';
import PropTypes from 'prop-types';
import { FormattedDate, useIntl } from '@edx/frontend-platform/i18n'; import { FormattedDate, useIntl } from '@edx/frontend-platform/i18n';
import { getConfig } from '@edx/frontend-platform/config'; import { getConfig } from '@edx/frontend-platform/config';
import { import {
Button, Hyperlink, Form, Stack, useToggle, Button, Hyperlink, Form, Stack, useToggle,
} from '@openedx/paragon'; } from '@openedx/paragon';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { AppContext } from '@edx/frontend-platform/react';
import { ContentTagsDrawerSheet } from '../../content-tags-drawer'; import { ReactNode } from 'react';
import TagCount from '../../generic/tag-count'; import { CourseOutlineStatusBar } from '@src/course-outline/data/types';
import { useHelpUrls } from '../../help-urls/hooks'; import { ContentTagsDrawerSheet } from '@src/content-tags-drawer';
import { useWaffleFlags } from '../../data/apiHooks'; import TagCount from '@src/generic/tag-count';
import { VIDEO_SHARING_OPTIONS } from '../constants'; import { useHelpUrls } from '@src/help-urls/hooks';
import { useContentTagsCount } from '../../generic/data/apiHooks'; 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 messages from './messages';
import { getVideoSharingOptionText } from '../utils';
const StatusBarItem = ({ title, children }) => ( interface StatusBarItemProps {
title: string,
children: ReactNode,
}
const StatusBarItem = ({ title, children }: StatusBarItemProps) => (
<div className="d-flex flex-column justify-content-between"> <div className="d-flex flex-column justify-content-between">
<h5>{title}</h5> <h5>{title}</h5>
<div className="d-flex align-items-center"> <div className="d-flex align-items-center">
@@ -27,24 +31,22 @@ const StatusBarItem = ({ title, children }) => (
</div> </div>
); );
StatusBarItem.propTypes = { export interface LegacyStatusBarProps {
title: PropTypes.string.isRequired, courseId: string,
children: PropTypes.node, isLoading: boolean,
}; openEnableHighlightsModal: () => void,
handleVideoSharingOptionChange: (value: string) => void,
statusBarData: CourseOutlineStatusBar,
}
StatusBarItem.defaultProps = { export const LegacyStatusBar = ({
children: null,
};
const StatusBar = ({
statusBarData, statusBarData,
isLoading, isLoading,
courseId, courseId,
openEnableHighlightsModal, openEnableHighlightsModal,
handleVideoSharingOptionChange, handleVideoSharingOptionChange,
}) => { }: LegacyStatusBarProps) => {
const intl = useIntl(); const intl = useIntl();
const { config } = useContext(AppContext);
const waffleFlags = useWaffleFlags(courseId); const waffleFlags = useWaffleFlags(courseId);
const { const {
@@ -65,7 +67,7 @@ const StatusBar = ({
const courseReleaseDateObj = moment.utc(courseReleaseDate, 'MMM DD, YYYY [at] HH:mm UTC', true); const courseReleaseDateObj = moment.utc(courseReleaseDate, 'MMM DD, YYYY [at] HH:mm UTC', true);
const checkListTitle = `${completedCourseLaunchChecks + completedCourseBestPracticesChecks}/${totalCourseLaunchChecks + totalCourseBestPracticesChecks}`; const checkListTitle = `${completedCourseLaunchChecks + completedCourseBestPracticesChecks}/${totalCourseLaunchChecks + totalCourseBestPracticesChecks}`;
const scheduleDestination = () => new URL(`settings/details/${courseId}#schedule`, config.STUDIO_BASE_URL).href; const scheduleDestination = () => new URL(`settings/details/${courseId}#schedule`, getConfig().STUDIO_BASE_URL).href;
const { const {
contentHighlights: contentHighlightsUrl, contentHighlights: contentHighlightsUrl,
@@ -90,7 +92,7 @@ const StatusBar = ({
> >
{courseReleaseDateObj.isValid() ? ( {courseReleaseDateObj.isValid() ? (
<FormattedDate <FormattedDate
value={courseReleaseDateObj} value={courseReleaseDateObj.toString()}
year="numeric" year="numeric"
month="short" month="short"
day="2-digit" day="2-digit"
@@ -139,7 +141,7 @@ const StatusBar = ({
{getConfig().ENABLE_TAGGING_TAXONOMY_PAGES === 'true' && ( {getConfig().ENABLE_TAGGING_TAXONOMY_PAGES === 'true' && (
<StatusBarItem title={intl.formatMessage(messages.courseTagsTitle)}> <StatusBarItem title={intl.formatMessage(messages.courseTagsTitle)}>
<div className="d-flex align-items-center"> <div className="d-flex align-items-center">
<TagCount count={courseTagCount} /> <TagCount count={courseTagCount || 0} />
{ /* eslint-disable-next-line jsx-a11y/anchor-is-valid */ } { /* eslint-disable-next-line jsx-a11y/anchor-is-valid */ }
<a <a
className="small ml-2" className="small ml-2"
@@ -164,7 +166,7 @@ const StatusBar = ({
<Form.Control <Form.Control
as="select" as="select"
defaultValue={videoSharingOptions} defaultValue={videoSharingOptions}
onChange={(e) => handleVideoSharingOptionChange(e.target.value)} onChange={(e: React.ChangeEvent<HTMLInputElement>) => handleVideoSharingOptionChange(e.target.value)}
> >
{Object.values(VIDEO_SHARING_OPTIONS).map((option) => ( {Object.values(VIDEO_SHARING_OPTIONS).map((option) => (
<option <option
@@ -196,25 +198,3 @@ const StatusBar = ({
</> </>
); );
}; };
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;

View File

@@ -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(
<NotificationStatusIcon />,
);
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();
});
});

View File

@@ -0,0 +1,38 @@
import { FormattedMessage } from '@edx/frontend-platform/i18n';
import { Icon } from '@openedx/paragon';
import { NotificationsNone } from '@openedx/paragon/icons';
import { HookType, useDynamicHookShim } from './hooks';
import messages from './messages';
// Component that actually calls the loaded hook
const 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;
}
return (
<small className="d-flex">
<Icon className="mr-1" size="md" src={NotificationsNone} />
<FormattedMessage
{...messages.notificationMetadataTitle}
values={{ count: notificationAppData?.tabsCount?.count }}
/>
</small>
);
};
// Main component
export const NotificationStatusIcon = () => {
const loadedHook = useDynamicHookShim();
// istanbul ignore if
if (!loadedHook) {
return null;
}
// Once loaded, delegate to a component that calls the hook
return <NotificationHookConsumer hook={loadedHook} />;
};

View File

@@ -0,0 +1,76 @@
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 messages from './messages';
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<StatusBarProps>) => render(
<StatusBar
courseId={courseId}
isLoading={isLoading}
statusBarData={statusBarData}
{...props}
/>,
);
describe('<StatusBar />', () => {
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();
});
});

View File

@@ -0,0 +1,143 @@
import moment, { Moment } from 'moment/moment';
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';
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 { NotificationStatusIcon } from './NotificationStatusIcon';
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 (
<Badge className="px-3 py-2" variant="success">
<FormattedMessage {...messages.activeBadgeText} />
</Badge>
);
case now.isBefore(startDate):
return (
<Badge className="px-3 py-2 bg-white text-success-400 border border-success-500" variant="success">
<FormattedMessage {...messages.upcomingBadgeText} />
</Badge>
);
case endDate.isValid() && endDate.isBefore(now):
return (
<Badge className="px-3 py-2" variant="light">
<FormattedMessage {...messages.archivedBadgeText} />
</Badge>
);
default:
// istanbul ignore next: this should not happen
return null;
}
};
const CourseDates = ({
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 (
<Link
className="small"
to={datesLink}
>
{startDateRaw}
</Link>
);
}
return (
<Link
className="small text-gray-700"
to={datesLink}
>
<FormattedDate
value={startDate.toString()}
year="numeric"
month="short"
day="2-digit"
/>
{endDate.isValid() && (
<>
{' - '}
<FormattedDate
value={endDate.toString()}
year="numeric"
month="short"
day="2-digit"
/>
</>
)}
</Link>
);
};
export interface StatusBarProps {
courseId: string;
isLoading: boolean;
statusBarData: CourseOutlineStatusBar;
}
export const StatusBar = ({
statusBarData,
isLoading,
courseId,
}: StatusBarProps) => {
const intl = useIntl();
const waffleFlags = useWaffleFlags(courseId);
const {
endDate,
courseReleaseDate,
checklist,
} = 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;
if (isLoading) {
return null;
}
return (
<Stack direction="horizontal" gap={4}>
<CourseBadge startDate={courseReleaseDateObj} endDate={endDateObj} />
<CourseDates
startDate={courseReleaseDateObj}
endDate={endDateObj}
startDateRaw={courseReleaseDate}
datesLink={waffleFlags.useNewScheduleDetailsPage ? `/course/${courseId}/settings/details/#schedule` : scheduleDestination()}
/>
<NotificationStatusIcon />
<Link
className="small text-primary-500 d-flex"
to={`/course/${courseId}/checklists`}
>
<Icon src={ChecklistRtl} size="md" className="mr-2" />
{checkListTitle} {intl.formatMessage(messages.checklistCompleted)}
</Link>
</Stack>
);
};

View File

@@ -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;
}

View File

@@ -25,6 +25,11 @@ const messages = defineMessages({
id: 'course-authoring.course-outline.status-bar.checklists.completed', id: 'course-authoring.course-outline.status-bar.checklists.completed',
defaultMessage: '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: { highlightEmailsTitle: {
id: 'course-authoring.course-outline.status-bar.highlight-emails', id: 'course-authoring.course-outline.status-bar.highlight-emails',
defaultMessage: 'Course highlight emails', defaultMessage: 'Course highlight emails',
@@ -71,6 +76,21 @@ const messages = defineMessages({
id: 'course-authoring.course-outline.status-bar.video-sharing.allOn.text', id: 'course-authoring.course-outline.status-bar.video-sharing.allOn.text',
defaultMessage: 'All Videos', 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; export default messages;

View File

@@ -67,6 +67,7 @@ export async function getCourseDetails(courseId: string, username: string): Prom
*/ */
export const waffleFlagDefaults = { export const waffleFlagDefaults = {
enableCourseOptimizer: false, enableCourseOptimizer: false,
enableNotifications: false,
enableCourseOptimizerCheckPrevRunLinks: false, enableCourseOptimizerCheckPrevRunLinks: false,
useNewHomePage: true, useNewHomePage: true,
useNewCustomPages: true, useNewCustomPages: true,

View File

@@ -123,3 +123,15 @@ export interface XBlock {
discussionEnabled?: boolean; discussionEnabled?: boolean;
upstreamInfo?: UpstreamInfo; upstreamInfo?: UpstreamInfo;
} }
interface OutlineError {
data?: string;
type: string;
}
export interface OutlinePageErrors {
outlineIndexApi?: OutlineError | null,
reindexApi?: OutlineError | null,
sectionLoadingApi?: OutlineError | null,
courseLaunchApi?: OutlineError | null,
}

View File

@@ -1,20 +1,32 @@
import React from 'react';
import PropTypes from 'prop-types';
import { ActionRow } from '@openedx/paragon'; 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 = ({ const SubHeader = ({
title, title,
subtitle, subtitle = '',
breadcrumbs, breadcrumbs,
contentTitle, contentTitle,
description, description = '',
instruction, instruction,
headerActions, headerActions,
titleActions, titleActions,
hideBorder, hideBorder = false,
withSubHeaderContent, withSubHeaderContent = true,
}) => ( }: SubHeaderProps) => (
<div className={`${!hideBorder && 'border-bottom border-light-400'} mb-3`}> <div className={`${!hideBorder && 'border-bottom border-light-400'} mb-2`}>
{breadcrumbs && ( {breadcrumbs && (
<div className="sub-header-breadcrumbs">{breadcrumbs}</div> <div className="sub-header-breadcrumbs">{breadcrumbs}</div>
)} )}
@@ -46,37 +58,4 @@ const SubHeader = ({
</div> </div>
); );
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; export default SubHeader;

View File

@@ -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_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_CERTIFICATE_PAGE: process.env.ENABLE_CERTIFICATE_PAGE || 'false',
ENABLE_COURSE_IMPORT_IN_LIBRARY: process.env.ENABLE_COURSE_IMPORT_IN_LIBRARY || '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_TAGGING_TAXONOMY_PAGES: process.env.ENABLE_TAGGING_TAXONOMY_PAGES || 'false',
ENABLE_CHECKLIST_QUALITY: process.env.ENABLE_CHECKLIST_QUALITY || 'true', ENABLE_CHECKLIST_QUALITY: process.env.ENABLE_CHECKLIST_QUALITY || 'true',
ENABLE_GRADING_METHOD_IN_PROBLEMS: process.env.ENABLE_GRADING_METHOD_IN_PROBLEMS === 'true', ENABLE_GRADING_METHOD_IN_PROBLEMS: process.env.ENABLE_GRADING_METHOD_IN_PROBLEMS === 'true',

View File

@@ -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,
}) => (
<PluginSlot
id="org.openedx.frontend.authoring.course_outline_header_actions.v1"
idAliases={['course_outline_header_actions_slot']}
pluginProps={{
isReIndexShow,
isSectionsExpanded,
isDisabledReindexButton,
headerNavigationsActions,
hasSections,
courseActions,
errors,
sections,
}}
>
<HeaderNavigations
headerNavigationsActions={headerNavigationsActions}
isReIndexShow={isReIndexShow}
isSectionsExpanded={isSectionsExpanded}
isDisabledReindexButton={isDisabledReindexButton}
hasSections={hasSections}
courseActions={courseActions}
errors={errors}
/>
</PluginSlot>
);
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;

View File

@@ -0,0 +1,63 @@
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<({
id: string,
displayName: string,
})>,
}
const CourseOutlineHeaderActionsSlot = ({
headerNavigationsActions,
isReIndexShow,
isSectionsExpanded,
isDisabledReindexButton,
hasSections,
courseActions,
errors,
sections,
}: CourseOutlineHeaderActionsSlotProps) => {
const showNewActionsBar = getConfig().ENABLE_COURSE_OUTLINE_NEW_DESIGN?.toString().toLowerCase() === 'true';
return (
<PluginSlot
id="org.openedx.frontend.authoring.course_outline_header_actions.v1"
idAliases={['course_outline_header_actions_slot']}
pluginProps={{
isReIndexShow,
isSectionsExpanded,
isDisabledReindexButton,
headerNavigationsActions,
hasSections,
courseActions,
errors,
sections,
}}
>
{showNewActionsBar
? (
<HeaderActions
actions={headerNavigationsActions}
courseActions={courseActions}
errors={errors}
/>
)
: (
<HeaderNavigations
headerNavigationsActions={headerNavigationsActions}
isReIndexShow={isReIndexShow}
isSectionsExpanded={isSectionsExpanded}
isDisabledReindexButton={isDisabledReindexButton}
hasSections={hasSections}
courseActions={courseActions}
errors={errors}
/>
)}
</PluginSlot>
);
};
export default CourseOutlineHeaderActionsSlot;

View File

@@ -0,0 +1,12 @@
/* istanbul ignore file */
export const useAppNotifications = () => ({
notificationAppData: {
tabsCount: {
count: 0,
},
},
});
export const NotificationsTray: React.FC = () => null;
export default NotificationsTray;

View File

@@ -4,7 +4,8 @@
"outDir": "dist", "outDir": "dist",
"baseUrl": "./src", "baseUrl": "./src",
"paths": { "paths": {
"@src/*": ["./*"] "@src/*": ["./*"],
"CourseAuthoring/*": ["./*"]
}, },
"types": ["jest", "@testing-library/jest-dom"] "types": ["jest", "@testing-library/jest-dom"]
}, },

View File

@@ -1,5 +1,7 @@
const path = require('path'); const path = require('path');
const { createConfig } = require('@openedx/frontend-build'); const { createConfig } = require('@openedx/frontend-build');
// eslint-disable-next-line import/no-extraneous-dependencies
const webpack = require('webpack');
const config = createConfig('webpack-dev', { const config = createConfig('webpack-dev', {
resolve: { resolve: {
@@ -14,6 +16,22 @@ const config = createConfig('webpack-dev', {
constants: false, 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.
// eslint-disable-next-line no-param-reassign
resource.request = path.resolve(__dirname, 'src/stubs/empty-notifications-plugin.tsx');
}
},
),
],
}); });
module.exports = config; module.exports = config;

View File

@@ -1,5 +1,7 @@
const path = require('path'); const path = require('path');
const { createConfig } = require('@openedx/frontend-build'); const { createConfig } = require('@openedx/frontend-build');
// eslint-disable-next-line import/no-extraneous-dependencies
const webpack = require('webpack');
const config = createConfig('webpack-prod', { const config = createConfig('webpack-prod', {
resolve: { resolve: {
@@ -14,6 +16,22 @@ const config = createConfig('webpack-prod', {
constants: false, 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.
// eslint-disable-next-line no-param-reassign
resource.request = path.resolve(__dirname, 'src/stubs/empty-notifications-plugin.tsx');
}
},
),
],
}); });
module.exports = config; module.exports = config;