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_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
|
||||
|
||||
@@ -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=''
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -22,6 +22,7 @@ const initialState = {
|
||||
savingStatus: '',
|
||||
statusBarData: {
|
||||
courseReleaseDate: '',
|
||||
endDate: '',
|
||||
highlightsEnabledForMessaging: false,
|
||||
isSelfPaced: false,
|
||||
checklist: {
|
||||
|
||||
@@ -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));
|
||||
|
||||
|
||||
@@ -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 | {};
|
||||
|
||||
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 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;
|
||||
@@ -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',
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
@@ -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;
|
||||
@@ -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',
|
||||
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;
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -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',
|
||||
|
||||
@@ -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",
|
||||
"baseUrl": "./src",
|
||||
"paths": {
|
||||
"@src/*": ["./*"]
|
||||
"@src/*": ["./*"],
|
||||
"CourseAuthoring/*": ["./*"]
|
||||
},
|
||||
"types": ["jest", "@testing-library/jest-dom"]
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user