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:
1
.env
1
.env
@@ -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
|
||||||
|
|||||||
@@ -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=''
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 ? (
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ const initialState = {
|
|||||||
savingStatus: '',
|
savingStatus: '',
|
||||||
statusBarData: {
|
statusBarData: {
|
||||||
courseReleaseDate: '',
|
courseReleaseDate: '',
|
||||||
|
endDate: '',
|
||||||
highlightsEnabledForMessaging: false,
|
highlightsEnabledForMessaging: false,
|
||||||
isSelfPaced: false,
|
isSelfPaced: false,
|
||||||
checklist: {
|
checklist: {
|
||||||
|
|||||||
@@ -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));
|
||||||
|
|
||||||
|
|||||||
@@ -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 | {};
|
||||||
|
|||||||
58
src/course-outline/header-navigations/HeaderActions.test.tsx
Normal file
58
src/course-outline/header-navigations/HeaderActions.test.tsx
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
102
src/course-outline/header-navigations/HeaderActions.tsx
Normal file
102
src/course-outline/header-navigations/HeaderActions.tsx
Normal 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;
|
||||||
@@ -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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
142
src/course-outline/header-navigations/HeaderNavigations.test.tsx
Normal file
142
src/course-outline/header-navigations/HeaderNavigations.test.tsx
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
@@ -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',
|
||||||
@@ -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',
|
||||||
|
|||||||
@@ -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,
|
||||||
@@ -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;
|
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
38
src/course-outline/status-bar/NotificationStatusIcon.tsx
Normal file
38
src/course-outline/status-bar/NotificationStatusIcon.tsx
Normal 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} />;
|
||||||
|
};
|
||||||
76
src/course-outline/status-bar/StatusBar.test.tsx
Normal file
76
src/course-outline/status-bar/StatusBar.test.tsx
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
143
src/course-outline/status-bar/StatusBar.tsx
Normal file
143
src/course-outline/status-bar/StatusBar.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
43
src/course-outline/status-bar/hooks.ts
Normal file
43
src/course-outline/status-bar/hooks.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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',
|
||||||
|
|||||||
@@ -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;
|
|
||||||
63
src/plugin-slots/CourseOutlineHeaderActionsSlot/index.tsx
Normal file
63
src/plugin-slots/CourseOutlineHeaderActionsSlot/index.tsx
Normal 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;
|
||||||
12
src/stubs/empty-notifications-plugin.tsx
Normal file
12
src/stubs/empty-notifications-plugin.tsx
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
/* istanbul ignore file */
|
||||||
|
export const useAppNotifications = () => ({
|
||||||
|
notificationAppData: {
|
||||||
|
tabsCount: {
|
||||||
|
count: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const NotificationsTray: React.FC = () => null;
|
||||||
|
|
||||||
|
export default NotificationsTray;
|
||||||
@@ -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"]
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user