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_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

View File

@@ -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=''

View File

@@ -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"

View File

@@ -198,7 +198,7 @@ export const CourseLibraries = () => {
<SubHeader
title={intl.formatMessage(messages.headingTitle)}
subtitle={intl.formatMessage(messages.headingSubtitle)}
headerActions={!showReviewAlert && outOfSyncCount > 0 && tabKey === CourseLibraryTabs.all && (
headerActions={(!showReviewAlert && outOfSyncCount > 0 && tabKey === CourseLibraryTabs.all) ? (
<Button
variant="primary"
onClick={onAlertReview}
@@ -206,7 +206,7 @@ export const CourseLibraries = () => {
>
{intl.formatMessage(messages.reviewUpdatesBtn)}
</Button>
)}
) : null}
hideBorder
/>
<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 { closestCorners } from '@dnd-kit/core';
import { logError } from '@edx/frontend-platform/logging';
@@ -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,
@@ -182,12 +183,10 @@ describe('<CourseOutline />', () => {
});
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 () => {
@@ -2486,4 +2485,20 @@ describe('<CourseOutline />', () => {
});
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 { useIntl } from '@edx/frontend-platform/i18n';
import { getConfig } from '@edx/frontend-platform';
import {
Container,
Layout,
@@ -7,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,
@@ -44,7 +47,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';
@@ -61,8 +63,11 @@ 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';
import { LegacyStatusBar } from './status-bar/LegacyStatusBar';
const CourseOutline = () => {
const intl = useIntl();
@@ -141,6 +146,9 @@ 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<string | null>(null);
@@ -314,8 +322,9 @@ const CourseOutline = () => {
) : null}
</TransitionReplace>
<SubHeader
title={intl.formatMessage(messages.headingTitle)}
title={courseName}
subtitle={intl.formatMessage(messages.headingSubtitle)}
hideBorder
headerActions={(
<CourseOutlineHeaderActionsSlot
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
lg={[{ span: 9 }, { span: 3 }]}
md={[{ span: 9 }, { span: 3 }]}
@@ -339,14 +365,24 @@ const CourseOutline = () => {
<Layout.Element>
<article>
<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">
<StatusBar
courseId={courseId}
isLoading={isLoading}
statusBarData={statusBarData}
openEnableHighlightsModal={openEnableHighlightsModal}
handleVideoSharingOptionChange={handleVideoSharingOptionChange}
/>
{!errors?.outlineIndexApi && (
<div className="pt-4">
{sections.length ? (

View File

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

View File

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

View File

@@ -4,6 +4,8 @@ export interface CourseStructure {
highlightsEnabledForMessaging: boolean,
videoSharingEnabled: boolean,
videoSharingOptions: string,
start: string,
end: string,
actions: XBlockActions,
}
@@ -33,6 +35,21 @@ export interface CourseDetails {
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 {
loadingStatus: {
outlineIndexLoadingStatus: string;
@@ -48,19 +65,7 @@ export interface CourseOutlineState {
};
outlineIndexData: object;
savingStatus: string;
statusBarData: {
courseReleaseDate: string;
highlightsEnabledForMessaging: boolean;
isSelfPaced: boolean;
checklist: {
totalCourseLaunchChecks: number;
completedCourseLaunchChecks: number;
totalCourseBestPracticesChecks: number;
completedCourseBestPracticesChecks: number;
};
videoSharingEnabled: boolean;
videoSharingOptions: string;
};
statusBarData: CourseOutlineStatusBar;
sectionsList: Array<XBlock>;
isCustomRelativeDatesActive: boolean;
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 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 = ({
<Button
iconBefore={IconAdd}
onClick={handleNewSection}
disabled={errors?.outlineIndexApi}
disabled={!(errors?.outlineIndexApi === undefined || errors?.outlineIndexApi === null)}
>
{intl.formatMessage(messages.newSectionButton)}
</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;

View File

@@ -5,6 +5,26 @@ 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',
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',
defaultMessage: 'Click to add a new section',
@@ -29,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',

View File

@@ -7,7 +7,8 @@ const messages = defineMessages({
},
headingSubtitle: {
id: 'course-authoring.course-outline.subTitle',
defaultMessage: 'Content',
defaultMessage: 'Course Outline',
description: 'Course Outline heading subTitle.',
},
alertSuccessTitle: {
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 { 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 { 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';
let store;
const mockPathname = '/foo-bar';
@@ -25,21 +25,22 @@ 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',
}),
}));
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<LegacyStatusBarProps>) => render(
<AppProvider store={store} messages={{}}>
<QueryClientProvider client={queryClient}>
<IntlProvider locale="en">
<StatusBar
<LegacyStatusBar
courseId={courseId}
isLoading={isLoading}
openEnableHighlightsModal={openEnableHighlightsModalMock}
@@ -71,7 +71,7 @@ const renderComponent = (props) => render(
</AppProvider>,
);
describe('<StatusBar />', () => {
describe('<LegacyStatusBar />', () => {
beforeEach(() => {
initializeMockApp({
authenticatedUser: {
@@ -84,7 +84,7 @@ describe('<StatusBar />', () => {
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('<StatusBar />', () => {
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,

View File

@@ -1,24 +1,28 @@
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 { ReactNode } from 'react';
import { CourseOutlineStatusBar } from '@src/course-outline/data/types';
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';
const StatusBarItem = ({ title, children }) => (
interface StatusBarItemProps {
title: string,
children: ReactNode,
}
const StatusBarItem = ({ title, children }: StatusBarItemProps) => (
<div className="d-flex flex-column justify-content-between">
<h5>{title}</h5>
<div className="d-flex align-items-center">
@@ -27,24 +31,22 @@ const StatusBarItem = ({ title, children }) => (
</div>
);
StatusBarItem.propTypes = {
title: PropTypes.string.isRequired,
children: PropTypes.node,
};
export interface LegacyStatusBarProps {
courseId: string,
isLoading: boolean,
openEnableHighlightsModal: () => void,
handleVideoSharingOptionChange: (value: string) => void,
statusBarData: CourseOutlineStatusBar,
}
StatusBarItem.defaultProps = {
children: null,
};
const StatusBar = ({
export const LegacyStatusBar = ({
statusBarData,
isLoading,
courseId,
openEnableHighlightsModal,
handleVideoSharingOptionChange,
}) => {
}: LegacyStatusBarProps) => {
const intl = useIntl();
const { config } = useContext(AppContext);
const waffleFlags = useWaffleFlags(courseId);
const {
@@ -65,7 +67,7 @@ const StatusBar = ({
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 scheduleDestination = () => new URL(`settings/details/${courseId}#schedule`, getConfig().STUDIO_BASE_URL).href;
const {
contentHighlights: contentHighlightsUrl,
@@ -90,7 +92,7 @@ const StatusBar = ({
>
{courseReleaseDateObj.isValid() ? (
<FormattedDate
value={courseReleaseDateObj}
value={courseReleaseDateObj.toString()}
year="numeric"
month="short"
day="2-digit"
@@ -139,7 +141,7 @@ const StatusBar = ({
{getConfig().ENABLE_TAGGING_TAXONOMY_PAGES === 'true' && (
<StatusBarItem title={intl.formatMessage(messages.courseTagsTitle)}>
<div className="d-flex align-items-center">
<TagCount count={courseTagCount} />
<TagCount count={courseTagCount || 0} />
{ /* eslint-disable-next-line jsx-a11y/anchor-is-valid */ }
<a
className="small ml-2"
@@ -164,7 +166,7 @@ const StatusBar = ({
<Form.Control
as="select"
defaultValue={videoSharingOptions}
onChange={(e) => handleVideoSharingOptionChange(e.target.value)}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => handleVideoSharingOptionChange(e.target.value)}
>
{Object.values(VIDEO_SHARING_OPTIONS).map((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',
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',
@@ -71,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;

View File

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

View File

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

View File

@@ -1,20 +1,32 @@
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,
withSubHeaderContent,
}) => (
<div className={`${!hideBorder && 'border-bottom border-light-400'} mb-3`}>
hideBorder = false,
withSubHeaderContent = true,
}: SubHeaderProps) => (
<div className={`${!hideBorder && 'border-bottom border-light-400'} mb-2`}>
{breadcrumbs && (
<div className="sub-header-breadcrumbs">{breadcrumbs}</div>
)}
@@ -46,37 +58,4 @@ const SubHeader = ({
</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;

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_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',

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",
"baseUrl": "./src",
"paths": {
"@src/*": ["./*"]
"@src/*": ["./*"],
"CourseAuthoring/*": ["./*"]
},
"types": ["jest", "@testing-library/jest-dom"]
},

View File

@@ -1,5 +1,7 @@
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', {
resolve: {
@@ -14,6 +16,22 @@ 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.
// eslint-disable-next-line no-param-reassign
resource.request = path.resolve(__dirname, 'src/stubs/empty-notifications-plugin.tsx');
}
},
),
],
});
module.exports = config;

View File

@@ -1,5 +1,7 @@
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', {
resolve: {
@@ -14,6 +16,22 @@ 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.
// eslint-disable-next-line no-param-reassign
resource.request = path.resolve(__dirname, 'src/stubs/empty-notifications-plugin.tsx');
}
},
),
],
});
module.exports = config;