feat: New header in course unit page [FC-0114] (#2751)

- `ENABLE_UNIT_PAGE_NEW_DESIGN` flag created
- New Status Bard implemented in the header of the course unit page.
- New buttons added in the header of the course unit page.
- Which user roles will this change impact? "Course Author".
This commit is contained in:
Chris Chávez
2026-01-26 12:52:50 -05:00
committed by GitHub
parent 1626d6808d
commit ef93e95dd7
22 changed files with 743 additions and 367 deletions

1
.env
View File

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

View File

@@ -41,6 +41,7 @@ ENABLE_COURSE_IMPORT_IN_LIBRARY=true
ENABLE_COURSE_OUTLINE_NEW_DESIGN=true
ENABLE_NEW_VIDEO_UPLOAD_PAGE=true
ENABLE_TAGGING_TAXONOMY_PAGES=true
ENABLE_UNIT_PAGE_NEW_DESIGN=true
BBB_LEARN_MORE_URL=''
HOTJAR_APP_ID=''
HOTJAR_VERSION=6

View File

@@ -36,6 +36,7 @@ ENABLE_CERTIFICATE_PAGE=true
ENABLE_COURSE_IMPORT_IN_LIBRARY=true
ENABLE_COURSE_OUTLINE_NEW_DESIGN=false
ENABLE_TAGGING_TAXONOMY_PAGES=true
ENABLE_UNIT_PAGE_NEW_DESIGN=true
BBB_LEARN_MORE_URL=''
INVITE_STUDENTS_EMAIL_TO="someone@domain.com"
ENABLE_CHECKLIST_QUALITY=true

View File

@@ -9,8 +9,24 @@
.course-unit {
min-width: 900px;
.sub-header {
// To clean the blank space in the bottom of the sub header
margin-bottom: -35px;
}
}
.course-unit__alert {
margin-bottom: 1.75rem;
}
.unit-header-status-bar {
.draft-badge {
background-color: #B4610E;
color: white;
}
.badge-label {
margin-top: 2px;
}
}

View File

@@ -2346,4 +2346,148 @@ describe('<CourseUnit />', () => {
// Does not render the "Add Components" section
expect(screen.queryByText(addComponentMessages.title.defaultMessage)).not.toBeInTheDocument();
});
it('displays the live state in the status bar', async () => {
setConfig({
...getConfig(),
ENABLE_UNIT_PAGE_NEW_DESIGN: 'true',
});
axiosMock
.onGet(getCourseSectionVerticalApiUrl(blockId))
.reply(200, {
...courseSectionVerticalMock,
xblock_info: {
...courseSectionVerticalMock.xblock_info,
currently_visible_to_students: true,
},
});
await executeThunk(fetchCourseSectionVerticalData(blockId), store.dispatch);
render(<RootWrapper />);
expect(await screen.findByText('Live')).toBeInTheDocument();
});
it('displays the staff only state in the status bar', async () => {
setConfig({
...getConfig(),
ENABLE_UNIT_PAGE_NEW_DESIGN: 'true',
});
axiosMock
.onGet(getCourseSectionVerticalApiUrl(blockId))
.reply(200, {
...courseSectionVerticalMock,
xblock_info: {
...courseSectionVerticalMock.xblock_info,
currently_visible_to_students: false,
visibility_state: 'staff_only',
},
});
await executeThunk(fetchCourseSectionVerticalData(blockId), store.dispatch);
render(<RootWrapper />);
expect(await screen.findByText('Staff Only')).toBeInTheDocument();
});
it('displays the scheduled state in the status bar', async () => {
setConfig({
...getConfig(),
ENABLE_UNIT_PAGE_NEW_DESIGN: 'true',
});
axiosMock
.onGet(getCourseSectionVerticalApiUrl(blockId))
.reply(200, {
...courseSectionVerticalMock,
xblock_info: {
...courseSectionVerticalMock.xblock_info,
currently_visible_to_students: false,
published: true,
},
});
await executeThunk(fetchCourseSectionVerticalData(blockId), store.dispatch);
render(<RootWrapper />);
expect(await screen.findByText('Scheduled')).toBeInTheDocument();
});
it('displays the draft changes state in the status bar', async () => {
setConfig({
...getConfig(),
ENABLE_UNIT_PAGE_NEW_DESIGN: 'true',
});
axiosMock
.onGet(getCourseSectionVerticalApiUrl(blockId))
.reply(200, {
...courseSectionVerticalMock,
xblock_info: {
...courseSectionVerticalMock.xblock_info,
published: true,
has_changes: true,
},
});
await executeThunk(fetchCourseSectionVerticalData(blockId), store.dispatch);
render(<RootWrapper />);
expect(await screen.findByText('Unpublished changes')).toBeInTheDocument();
});
it('displays discussions enabled label in the status bar', async () => {
setConfig({
...getConfig(),
ENABLE_UNIT_PAGE_NEW_DESIGN: 'true',
});
axiosMock
.onGet(getCourseSectionVerticalApiUrl(blockId))
.reply(200, {
...courseSectionVerticalMock,
xblock_info: {
...courseSectionVerticalMock.xblock_info,
discussion_enabled: true,
},
});
await executeThunk(fetchCourseSectionVerticalData(blockId), store.dispatch);
render(<RootWrapper />);
expect(await screen.findByText('Discussions Enabled')).toBeInTheDocument();
});
it('displays group access with one group in the status bar', async () => {
setConfig({
...getConfig(),
ENABLE_UNIT_PAGE_NEW_DESIGN: 'true',
});
axiosMock
.onGet(getCourseSectionVerticalApiUrl(blockId))
.reply(200, {
...courseSectionVerticalMock,
xblock_info: {
...courseSectionVerticalMock.xblock_info,
user_partition_info: {
...courseSectionVerticalMock.xblock_info.user_partition_info,
selected_partition_index: 1,
selected_groups_label: 'Visibility group 1',
},
},
});
await executeThunk(fetchCourseSectionVerticalData(blockId), store.dispatch);
render(<RootWrapper />);
expect(await screen.findByText('Access: Visibility group 1')).toBeInTheDocument();
});
it('displays group access with multiple groups in the status bar', async () => {
setConfig({
...getConfig(),
ENABLE_UNIT_PAGE_NEW_DESIGN: 'true',
});
axiosMock
.onGet(getCourseSectionVerticalApiUrl(blockId))
.reply(200, {
...courseSectionVerticalMock,
xblock_info: {
...courseSectionVerticalMock.xblock_info,
user_partition_info: {
...courseSectionVerticalMock.xblock_info.user_partition_info,
selected_partition_index: 1,
selected_groups_label: 'Visibility group 1, Visibility group 2, Visibility group 3',
},
},
});
await executeThunk(fetchCourseSectionVerticalData(blockId), store.dispatch);
render(<RootWrapper />);
expect(await screen.findByText('Access: 3 Groups')).toBeInTheDocument();
});
});

View File

@@ -1,15 +1,26 @@
import { useEffect } from 'react';
import { useSelector } from 'react-redux';
import { useParams } from 'react-router-dom';
import type { MessageDescriptor } from 'react-intl';
import {
Alert, Container, Layout, Button, TransitionReplace,
Stack,
Badge,
Icon,
OverlayTrigger,
Tooltip,
} from '@openedx/paragon';
import { useIntl } from '@edx/frontend-platform/i18n';
import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n';
import {
Warning as WarningIcon,
CheckCircle as CheckCircleIcon,
Lock,
AccessTimeFilled,
Groups,
QuestionAnswer,
} from '@openedx/paragon/icons';
import { useCourseAuthoringContext } from '@src/CourseAuthoringContext';
import DraftIcon from '@src/generic/DraftIcon';
import { CourseAuthoringUnitSidebarSlot } from '../plugin-slots/CourseAuthoringUnitSidebarSlot';
import { getProcessingNotification } from '../generic/processing-notification/data/selectors';
@@ -33,6 +44,119 @@ import XBlockContainerIframe from './xblock-container-iframe';
import MoveModal from './move-modal';
import IframePreviewLibraryXBlockChanges from './preview-changes';
import CourseUnitHeaderActionsSlot from '../plugin-slots/CourseUnitHeaderActionsSlot';
import { UNIT_VISIBILITY_STATES } from './constants';
import { isUnitPageNewDesignEnabled } from './utils';
const StatusBar = ({ courseUnit }: { courseUnit: any }) => {
const { selectedPartitionIndex, selectedGroupsLabel } = courseUnit.userPartitionInfo ?? {};
const hasGroups = selectedPartitionIndex !== -1 && !Number.isNaN(selectedPartitionIndex) && selectedGroupsLabel;
let groupsCount = 0;
if (hasGroups) {
groupsCount = selectedGroupsLabel.split(',').length;
}
let visibilityChipData = {
variant: 'warning',
className: 'draft-badge',
text: messages.statusBarDraftNeverPublished,
icon: DraftIcon,
} as {
variant: string,
className?: string,
text: MessageDescriptor,
icon: React.ComponentType,
};
if (courseUnit.currentlyVisibleToStudents) {
visibilityChipData = {
variant: 'success',
text: messages.statusBarLiveBadge,
icon: CheckCircleIcon,
};
} else if (courseUnit.visibilityState === UNIT_VISIBILITY_STATES.staffOnly) {
visibilityChipData = {
variant: 'secondary',
text: messages.statusBarStaffOnly,
icon: Lock,
};
} else if (courseUnit.published) {
visibilityChipData = {
variant: 'info',
text: messages.statusBarScheduledBadge,
icon: AccessTimeFilled,
};
}
return (
<Stack direction="horizontal" gap={3}>
<Badge
variant={visibilityChipData.variant}
className={`px-3 py-2 ${visibilityChipData.className || ''}`}
>
<Stack direction="horizontal" gap={2}>
<Icon size="xs" src={visibilityChipData.icon} />
<span className="badge-label">
<FormattedMessage {...visibilityChipData.text} />
</span>
</Stack>
</Badge>
{courseUnit.published && courseUnit.hasChanges && (
<Badge
variant="warning"
className="px-3 py-2 draft-badge"
>
<Stack direction="horizontal" gap={1}>
<Icon size="xs" src={DraftIcon} />
<span className="badge-label">
<FormattedMessage {...messages.statusBarDraftChangesBadge} />
</span>
</Stack>
</Badge>
)}
{groupsCount === 1 && (
<Stack direction="horizontal" gap={1}>
<Icon src={Groups} />
<span>
<FormattedMessage
{...messages.statusBarGroupAccessOneGroup}
values={{
groupName: selectedGroupsLabel,
}}
/>
</span>
</Stack>
)}
{groupsCount > 1 && (
<OverlayTrigger
placement="top"
overlay={(
<Tooltip id="unit-group-access-tooltip">
{selectedGroupsLabel}
</Tooltip>
)}
>
<Stack direction="horizontal" gap={1}>
<Icon src={Groups} />
<span>
<FormattedMessage
{...messages.statusBarGroupAccessMultipleGroup}
values={{
groupsCount,
}}
/>
</span>
</Stack>
</OverlayTrigger>
)}
{courseUnit.discussionEnabled && (
<Stack direction="horizontal" gap={1}>
<Icon src={QuestionAnswer} />
<FormattedMessage {...messages.statusBarDiscussionsEnabled} />
</Stack>
)}
</Stack>
);
};
const CourseUnit = () => {
const { blockId } = useParams();
@@ -74,6 +198,7 @@ const CourseUnit = () => {
handleNavigateToTargetUnit,
addComponentTemplateData,
} = useCourseUnit({ courseId, blockId });
const layoutGrid = useLayoutGrid(unitCategory, isUnitLibraryType);
const readOnly = !!courseUnit.readOnly;
@@ -121,7 +246,7 @@ const CourseUnit = () => {
: intl.formatMessage(messages.alertMoveSuccessDescription, { title: movedXBlockParams.title })}
aria-hidden={movedXBlockParams.isSuccess}
dismissible
actions={movedXBlockParams.isUndo ? null : [
actions={movedXBlockParams.isUndo ? undefined : [
<Button
onClick={handleRollbackMovedXBlock}
key="xblock-moved-alert-undo-move-button"
@@ -146,7 +271,6 @@ const CourseUnit = () => {
{
link: (
<Alert.Link
className="ml-1"
href={courseUnit.upstreamInfo.upstreamLink}
>
{intl.formatMessage(messages.alertLibraryUnitReadOnlyLinkText)}
@@ -183,6 +307,11 @@ const CourseUnit = () => {
/>
)}
/>
<div className="unit-header-status-bar h5 mt-2 mb-4 font-weight-normal">
{isUnitPageNewDesignEnabled() && isUnitVerticalType && (
<StatusBar courseUnit={courseUnit} />
)}
</div>
{isUnitVerticalType && (
<Sequence
courseId={courseId}
@@ -208,15 +337,17 @@ const CourseUnit = () => {
courseId={courseId}
/>
)}
<XBlockContainerIframe
courseId={courseId}
blockId={blockId}
isUnitVerticalType={isUnitVerticalType}
unitXBlockActions={unitXBlockActions}
courseVerticalChildren={courseVerticalChildren.children}
handleConfigureSubmit={handleConfigureSubmit}
/>
{!readOnly && (
{blockId && (
<XBlockContainerIframe
courseId={courseId}
blockId={blockId}
isUnitVerticalType={isUnitVerticalType}
unitXBlockActions={unitXBlockActions}
courseVerticalChildren={courseVerticalChildren.children}
handleConfigureSubmit={handleConfigureSubmit}
/>
)}
{!readOnly && blockId && (
<AddComponent
parentLocator={blockId}
isSplitTestType={isSplitTestType}
@@ -226,7 +357,7 @@ const CourseUnit = () => {
addComponentTemplateData={addComponentTemplateData}
/>
)}
{!readOnly && showPasteXBlock && canPasteComponent && isUnitVerticalType && (
{!readOnly && showPasteXBlock && canPasteComponent && isUnitVerticalType && sharedClipboardData && (
<PasteComponent
clipboardData={sharedClipboardData}
onClick={
@@ -244,15 +375,17 @@ const CourseUnit = () => {
<IframePreviewLibraryXBlockChanges />
</Layout.Element>
<Layout.Element>
<CourseAuthoringUnitSidebarSlot
courseId={courseId}
blockId={blockId}
unitTitle={unitTitle}
xBlocks={courseVerticalChildren.children}
readOnly={readOnly}
isUnitVerticalType={isUnitVerticalType}
isSplitTestType={isSplitTestType}
/>
{blockId && (
<CourseAuthoringUnitSidebarSlot
courseId={courseId}
blockId={blockId}
unitTitle={unitTitle}
xBlocks={courseVerticalChildren.children}
readOnly={readOnly}
isUnitVerticalType={isUnitVerticalType}
isSplitTestType={isSplitTestType}
/>
)}
</Layout.Element>
</Layout>
</section>

View File

@@ -1,54 +0,0 @@
import PropTypes from 'prop-types';
import { useIntl } from '@edx/frontend-platform/i18n';
import { Button } from '@openedx/paragon';
import { Edit as EditIcon } from '@openedx/paragon/icons';
import { COURSE_BLOCK_NAMES } from '../../constants';
import messages from './messages';
const HeaderNavigations = ({ headerNavigationsActions, category }) => {
const intl = useIntl();
const { handleViewLive, handlePreview, handleEdit } = headerNavigationsActions;
return (
<nav className="header-navigations ml-auto flex-shrink-0">
{category === COURSE_BLOCK_NAMES.vertical.id && (
<>
<Button
variant="outline-primary"
onClick={handleViewLive}
>
{intl.formatMessage(messages.viewLiveButton)}
</Button>
<Button
variant="outline-primary"
onClick={handlePreview}
>
{intl.formatMessage(messages.previewButton)}
</Button>
</>
)}
{[COURSE_BLOCK_NAMES.libraryContent.id, COURSE_BLOCK_NAMES.splitTest.id].includes(category) && (
<Button
iconBefore={EditIcon}
variant="outline-primary"
onClick={handleEdit}
data-testid="header-edit-button"
>
{intl.formatMessage(messages.editButton)}
</Button>
)}
</nav>
);
};
HeaderNavigations.propTypes = {
headerNavigationsActions: PropTypes.shape({
handleViewLive: PropTypes.func.isRequired,
handlePreview: PropTypes.func.isRequired,
handleEdit: PropTypes.func.isRequired,
}).isRequired,
category: PropTypes.string.isRequired,
};
export default HeaderNavigations;

View File

@@ -0,0 +1,98 @@
import { useIntl } from '@edx/frontend-platform/i18n';
import {
Button, ButtonGroup, Stack,
} from '@openedx/paragon';
import {
Add, Edit as EditIcon, FindInPage, InfoOutline,
} from '@openedx/paragon/icons';
import { COURSE_BLOCK_NAMES } from '@src/constants';
import messages from './messages';
import { isUnitPageNewDesignEnabled } from '../utils';
type HeaderNavigationActions = {
handleViewLive: () => void;
handlePreview: () => void;
handleEdit: () => void;
};
type HeaderNavigationsProps = {
headerNavigationsActions: HeaderNavigationActions;
category: string;
};
/**
* Generic header navigations to be used in this pages:
* - Unit page
* - Legacy library content page
* - Split test page
*/
const HeaderNavigations = ({ headerNavigationsActions, category }: HeaderNavigationsProps) => {
const intl = useIntl();
const {
handleViewLive,
handlePreview,
handleEdit,
} = headerNavigationsActions;
const showNewDesignButtons = isUnitPageNewDesignEnabled();
return (
<nav className="header-navigations ml-auto flex-shrink-0">
{/**
* Action buttons used in the unit page
*/}
{category === COURSE_BLOCK_NAMES.vertical.id && (
<Stack direction="horizontal" gap={3}>
{showNewDesignButtons && (
<>
<Button
variant="outline-primary"
iconBefore={InfoOutline}
>
{intl.formatMessage(messages.infoButton)}
</Button>
<Button
variant="outline-primary"
iconBefore={Add}
>
{intl.formatMessage(messages.addButton)}
</Button>
</>
)}
<ButtonGroup>
<Button
variant="outline-primary"
onClick={handlePreview}
iconBefore={FindInPage}
>
{intl.formatMessage(messages.previewButton)}
</Button>
<Button
variant="outline-primary"
onClick={handleViewLive}
>
{intl.formatMessage(messages.viewLiveButton)}
</Button>
</ButtonGroup>
</Stack>
)}
{/**
* Action buttons used in legacy libraries content page and split test page
*/}
{[COURSE_BLOCK_NAMES.libraryContent.id, COURSE_BLOCK_NAMES.splitTest.id].includes(category) && (
<Button
iconBefore={EditIcon}
variant="outline-primary"
onClick={handleEdit}
data-testid="header-edit-button"
>
{intl.formatMessage(messages.editButton)}
</Button>
)}
</nav>
);
};
export default HeaderNavigations;

View File

@@ -16,6 +16,31 @@ const messages = defineMessages({
defaultMessage: 'Edit',
description: 'The unit edit button text',
},
addButton: {
id: 'course-authoring.course-unit.button.add',
defaultMessage: 'Add',
description: 'The unit add button text',
},
moreActionsButtonAriaLabel: {
id: 'course-authoring.course-unit.button.more-actions',
defaultMessage: 'More actions',
description: 'The unit more actions button aria-label',
},
analyticsMenu: {
id: 'course-authoring.course-unit.button.analytics',
defaultMessage: 'Analytics',
description: 'The unit analytics menu text',
},
alignMenu: {
id: 'course-authoring.course-unit.button.align',
defaultMessage: 'Align',
description: 'The unit align menu text',
},
infoButton: {
id: 'course-authoring.course-unit.button.unit-info',
defaultMessage: 'Unit Info',
description: 'The unit info button text',
},
});
export default messages;

View File

@@ -1,113 +0,0 @@
import { useEffect, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import PropTypes from 'prop-types';
import { useIntl } from '@edx/frontend-platform/i18n';
import { Form, IconButton, useToggle } from '@openedx/paragon';
import {
EditOutline as EditIcon,
Settings as SettingsIcon,
} from '@openedx/paragon/icons';
import ConfigureModal from '../../generic/configure-modal/ConfigureModal';
import { COURSE_BLOCK_NAMES } from '../../constants';
import { getCourseUnitData } from '../data/selectors';
import { updateQueryPendingStatus } from '../data/slice';
import messages from './messages';
const HeaderTitle = ({
unitTitle,
isTitleEditFormOpen,
handleTitleEdit,
handleTitleEditSubmit,
handleConfigureSubmit,
}) => {
const intl = useIntl();
const dispatch = useDispatch();
const [titleValue, setTitleValue] = useState(unitTitle);
const currentItemData = useSelector(getCourseUnitData);
const [isConfigureModalOpen, openConfigureModal, closeConfigureModal] = useToggle(false);
const { selectedPartitionIndex, selectedGroupsLabel } = currentItemData.userPartitionInfo ?? {};
const isXBlockComponent = [
COURSE_BLOCK_NAMES.libraryContent.id,
COURSE_BLOCK_NAMES.splitTest.id,
COURSE_BLOCK_NAMES.component.id,
].includes(currentItemData.category);
const onConfigureSubmit = (...arg) => {
handleConfigureSubmit(currentItemData.id, ...arg, closeConfigureModal);
};
const getVisibilityMessage = () => {
let message;
if (selectedPartitionIndex !== -1 && !Number.isNaN(selectedPartitionIndex) && selectedGroupsLabel) {
message = intl.formatMessage(messages.definedVisibilityMessage, { selectedGroupsLabel });
} else if (currentItemData.hasPartitionGroupComponents) {
message = intl.formatMessage(messages.commonVisibilityMessage);
}
return message ? (<p className="header-title__visibility-message mb-0">{message}</p>) : null;
};
useEffect(() => {
setTitleValue(unitTitle);
dispatch(updateQueryPendingStatus(true));
}, [unitTitle]);
return (
<>
<div className="d-flex align-items-center lead" data-testid="unit-header-title">
{isTitleEditFormOpen ? (
<Form.Group className="m-0">
<Form.Control
ref={(e) => e && e.focus()}
value={titleValue}
name="displayName"
onChange={(e) => setTitleValue(e.target.value)}
aria-label={intl.formatMessage(messages.ariaLabelButtonEdit)}
onBlur={() => handleTitleEditSubmit(titleValue)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
handleTitleEditSubmit(titleValue);
}
}}
/>
</Form.Group>
) : unitTitle}
<IconButton
alt={intl.formatMessage(messages.altButtonEdit)}
className="ml-1 flex-shrink-0"
iconAs={EditIcon}
onClick={handleTitleEdit}
/>
<IconButton
alt={intl.formatMessage(messages.altButtonSettings)}
className="flex-shrink-0"
iconAs={SettingsIcon}
onClick={openConfigureModal}
/>
<ConfigureModal
isOpen={isConfigureModalOpen}
onClose={closeConfigureModal}
onConfigureSubmit={onConfigureSubmit}
currentItemData={currentItemData}
isSelfPaced={false}
isXBlockComponent={isXBlockComponent}
userPartitionInfo={currentItemData?.userPartitionInfo || {}}
/>
</div>
{getVisibilityMessage()}
</>
);
};
export default HeaderTitle;
HeaderTitle.propTypes = {
unitTitle: PropTypes.string.isRequired,
isTitleEditFormOpen: PropTypes.bool.isRequired,
handleTitleEdit: PropTypes.func.isRequired,
handleTitleEditSubmit: PropTypes.func.isRequired,
handleConfigureSubmit: PropTypes.func.isRequired,
};

View File

@@ -2,3 +2,21 @@
font-size: var(--pgn-typography-font-size-sm);
font-weight: var(--pgn-typography-font-weight-normal);
}
.unit-header-title {
.edit-button {
opacity: 0;
transition: opacity .3s linear;
margin-right: .5rem;
&:focus {
opacity: 1;
}
}
&:hover {
.edit-button {
opacity: 1;
}
}
}

View File

@@ -1,170 +0,0 @@
import MockAdapter from 'axios-mock-adapter';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { render, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import { AppProvider } from '@edx/frontend-platform/react';
import { initializeMockApp } from '@edx/frontend-platform';
import initializeStore from '../../store';
import { executeThunk } from '../../utils';
import { getCourseSectionVerticalApiUrl } from '../data/api';
import { fetchCourseSectionVerticalData } from '../data/thunk';
import { courseSectionVerticalMock } from '../__mocks__';
import HeaderTitle from './HeaderTitle';
import messages from './messages';
const blockId = '123';
const unitTitle = 'Getting Started';
const isTitleEditFormOpen = false;
const handleTitleEdit = jest.fn();
const handleTitleEditSubmit = jest.fn();
const handleConfigureSubmit = jest.fn();
let store;
let axiosMock;
const renderComponent = (props) => render(
<AppProvider store={store}>
<IntlProvider locale="en">
<HeaderTitle
unitTitle={unitTitle}
isTitleEditFormOpen={isTitleEditFormOpen}
handleTitleEdit={handleTitleEdit}
handleTitleEditSubmit={handleTitleEditSubmit}
handleConfigureSubmit={handleConfigureSubmit}
{...props}
/>
</IntlProvider>
</AppProvider>,
);
describe('<HeaderTitle />', () => {
beforeEach(async () => {
initializeMockApp({
authenticatedUser: {
userId: 3,
username: 'abc123',
administrator: true,
roles: [],
},
});
store = initializeStore();
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
axiosMock
.onGet(getCourseSectionVerticalApiUrl(blockId))
.reply(200, courseSectionVerticalMock);
await executeThunk(fetchCourseSectionVerticalData(blockId), store.dispatch);
});
it('render HeaderTitle component correctly', () => {
const { getByText, getByRole } = renderComponent();
expect(getByText(unitTitle)).toBeInTheDocument();
expect(getByRole('button', { name: messages.altButtonEdit.defaultMessage })).toBeInTheDocument();
expect(getByRole('button', { name: messages.altButtonSettings.defaultMessage })).toBeInTheDocument();
});
it('render HeaderTitle with open edit form', () => {
const { getByRole } = renderComponent({
isTitleEditFormOpen: true,
});
expect(getByRole('textbox', { name: messages.ariaLabelButtonEdit.defaultMessage })).toBeInTheDocument();
expect(getByRole('textbox', { name: messages.ariaLabelButtonEdit.defaultMessage })).toHaveValue(unitTitle);
expect(getByRole('button', { name: messages.altButtonEdit.defaultMessage })).toBeEnabled();
expect(getByRole('button', { name: messages.altButtonSettings.defaultMessage })).toBeEnabled();
});
it('Units sourced from upstream show a enabled edit button', async () => {
// Override mock unit with one sourced from an upstream library
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
axiosMock
.onGet(getCourseSectionVerticalApiUrl(blockId))
.reply(200, {
...courseSectionVerticalMock,
xblock_info: {
...courseSectionVerticalMock.xblock_info,
upstreamInfo: {
...courseSectionVerticalMock.xblock_info.upstreamInfo,
upstreamRef: 'lct:org:lib:unit:unit-1',
},
},
});
await executeThunk(fetchCourseSectionVerticalData(blockId), store.dispatch);
const { getByRole } = renderComponent();
expect(getByRole('button', { name: messages.altButtonEdit.defaultMessage })).toBeEnabled();
expect(getByRole('button', { name: messages.altButtonSettings.defaultMessage })).toBeEnabled();
});
it('calls toggle edit title form by clicking on Edit button', async () => {
const user = userEvent.setup();
const { getByRole } = renderComponent();
const editTitleButton = getByRole('button', { name: messages.altButtonEdit.defaultMessage });
await user.click(editTitleButton);
expect(handleTitleEdit).toHaveBeenCalledTimes(1);
});
it('calls saving title by clicking outside or press Enter key', async () => {
const user = userEvent.setup();
const { getByRole } = renderComponent({
isTitleEditFormOpen: true,
});
const titleField = getByRole('textbox', { name: messages.ariaLabelButtonEdit.defaultMessage });
await user.type(titleField, ' 1');
expect(titleField).toHaveValue(`${unitTitle} 1`);
await user.click(document.body);
expect(handleTitleEditSubmit).toHaveBeenCalledTimes(1);
await user.click(titleField);
await user.type(titleField, ' 2[Enter]');
expect(titleField).toHaveValue(`${unitTitle} 1 2`);
expect(handleTitleEditSubmit).toHaveBeenCalledTimes(2);
});
it('displays a visibility message with the selected groups for the unit', async () => {
axiosMock
.onGet(getCourseSectionVerticalApiUrl(blockId))
.reply(200, {
...courseSectionVerticalMock,
xblock_info: {
...courseSectionVerticalMock.xblock_info,
user_partition_info: {
...courseSectionVerticalMock.xblock_info.user_partition_info,
selected_partition_index: 1,
selected_groups_label: 'Visibility group 1',
},
},
});
await executeThunk(fetchCourseSectionVerticalData(blockId), store.dispatch);
const { getByText } = renderComponent();
const visibilityMessage = messages.definedVisibilityMessage.defaultMessage
.replace('{selectedGroupsLabel}', 'Visibility group 1');
await waitFor(() => {
expect(getByText(visibilityMessage)).toBeInTheDocument();
});
});
it('displays a visibility message with the selected groups for some of xblock', async () => {
axiosMock
.onGet(getCourseSectionVerticalApiUrl(blockId))
.reply(200, {
...courseSectionVerticalMock,
xblock_info: {
...courseSectionVerticalMock.xblock_info,
has_partition_group_components: true,
},
});
await executeThunk(fetchCourseSectionVerticalData(blockId), store.dispatch);
const { getByText } = renderComponent();
await waitFor(() => {
expect(getByText(messages.commonVisibilityMessage.defaultMessage)).toBeInTheDocument();
});
});
});

View File

@@ -0,0 +1,113 @@
import MockAdapter from 'axios-mock-adapter';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { initializeMocks, render, screen } from '@src/testUtils';
import userEvent from '@testing-library/user-event';
import { executeThunk } from '@src/utils';
import { getCourseSectionVerticalApiUrl } from '../data/api';
import { fetchCourseSectionVerticalData } from '../data/thunk';
import { courseSectionVerticalMock } from '../__mocks__';
import HeaderTitle from './HeaderTitle';
import messages from './messages';
const blockId = '123';
const unitTitle = 'Getting Started';
const isTitleEditFormOpen = false;
const handleTitleEdit = jest.fn();
const handleTitleEditSubmit = jest.fn();
const handleConfigureSubmit = jest.fn();
let store;
let axiosMock;
const renderComponent = (props?: any) => render(
<HeaderTitle
unitTitle={unitTitle}
isTitleEditFormOpen={isTitleEditFormOpen}
handleTitleEdit={handleTitleEdit}
handleTitleEditSubmit={handleTitleEditSubmit}
handleConfigureSubmit={handleConfigureSubmit}
{...props}
/>,
);
describe('<HeaderTitle />', () => {
beforeEach(async () => {
const mocks = initializeMocks();
store = mocks.reduxStore;
axiosMock = mocks.axiosMock;
axiosMock
.onGet(getCourseSectionVerticalApiUrl(blockId))
.reply(200, courseSectionVerticalMock);
await executeThunk(fetchCourseSectionVerticalData(blockId), store.dispatch);
});
it('render HeaderTitle component correctly', () => {
renderComponent();
expect(screen.getByText(unitTitle)).toBeInTheDocument();
expect(screen.getByRole('button', { name: messages.altButtonEdit.defaultMessage })).toBeInTheDocument();
expect(screen.getByRole('button', { name: messages.altButtonSettings.defaultMessage })).toBeInTheDocument();
});
it('render HeaderTitle with open edit form', () => {
renderComponent({
isTitleEditFormOpen: true,
});
expect(screen.getByRole('textbox', { name: messages.ariaLabelButtonEdit.defaultMessage })).toBeInTheDocument();
expect(screen.getByRole('textbox', { name: messages.ariaLabelButtonEdit.defaultMessage })).toHaveValue(unitTitle);
expect(screen.getByRole('button', { name: messages.altButtonEdit.defaultMessage })).toBeEnabled();
expect(screen.getByRole('button', { name: messages.altButtonSettings.defaultMessage })).toBeEnabled();
});
it('Units sourced from upstream show a enabled edit button', async () => {
// Override mock unit with one sourced from an upstream library
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
axiosMock
.onGet(getCourseSectionVerticalApiUrl(blockId))
.reply(200, {
...courseSectionVerticalMock,
xblock_info: {
...courseSectionVerticalMock.xblock_info,
upstreamInfo: {
...courseSectionVerticalMock.xblock_info.upstreamInfo,
upstreamRef: 'lct:org:lib:unit:unit-1',
},
},
});
await executeThunk(fetchCourseSectionVerticalData(blockId), store.dispatch);
renderComponent();
expect(screen.getByRole('button', { name: messages.altButtonEdit.defaultMessage })).toBeEnabled();
expect(screen.getByRole('button', { name: messages.altButtonSettings.defaultMessage })).toBeEnabled();
});
it('calls toggle edit title form by clicking on Edit button', async () => {
const user = userEvent.setup();
renderComponent();
const editTitleButton = screen.getByRole('button', { name: messages.altButtonEdit.defaultMessage });
await user.click(editTitleButton);
expect(handleTitleEdit).toHaveBeenCalledTimes(1);
});
it('calls saving title by clicking outside or press Enter key', async () => {
const user = userEvent.setup();
renderComponent({
isTitleEditFormOpen: true,
});
const titleField = screen.getByRole('textbox', { name: messages.ariaLabelButtonEdit.defaultMessage });
await user.type(titleField, ' 1');
expect(titleField).toHaveValue(`${unitTitle} 1`);
await user.click(document.body);
expect(handleTitleEditSubmit).toHaveBeenCalledTimes(1);
await user.click(titleField);
await user.type(titleField, ' 2[Enter]');
expect(titleField).toHaveValue(`${unitTitle} 1 2`);
expect(handleTitleEditSubmit).toHaveBeenCalledTimes(2);
});
});

View File

@@ -0,0 +1,121 @@
import { useEffect, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import {
Form, IconButton, useToggle,
} from '@openedx/paragon';
import {
EditOutline as EditIcon,
Settings as SettingsIcon,
} from '@openedx/paragon/icons';
import ConfigureModal from '@src/generic/configure-modal/ConfigureModal';
import { COURSE_BLOCK_NAMES } from '@src/constants';
import { useIntl } from '@edx/frontend-platform/i18n';
import { getCourseUnitData } from '../data/selectors';
import { updateQueryPendingStatus } from '../data/slice';
import messages from './messages';
import { isUnitPageNewDesignEnabled } from '../utils';
type HeaderTitleProps = {
unitTitle: string;
isTitleEditFormOpen: boolean;
handleTitleEdit: () => void;
handleTitleEditSubmit: (title: string) => void;
handleConfigureSubmit: (
id: string,
isVisible: boolean,
groupAccess: boolean,
isDiscussionEnabled: boolean,
closeModalFn: (value: boolean) => void
) => void;
};
/**
* Component that renders the title and extra action buttons:
* - Edit button: Hidden, It appears when you hover over it.
* The title becomes a text form.
* - Settings button: Shown only in the legacy unit page.
* Opens a settings modal.
*/
const HeaderTitle = ({
unitTitle,
isTitleEditFormOpen,
handleTitleEdit,
handleTitleEditSubmit,
handleConfigureSubmit,
}: HeaderTitleProps) => {
const intl = useIntl();
const dispatch = useDispatch();
const [titleValue, setTitleValue] = useState(unitTitle);
const currentItemData = useSelector(getCourseUnitData);
const [isConfigureModalOpen, openConfigureModal, closeConfigureModal] = useToggle(false);
const isXBlockComponent = [
COURSE_BLOCK_NAMES.libraryContent.id,
COURSE_BLOCK_NAMES.splitTest.id,
COURSE_BLOCK_NAMES.component.id,
].includes(currentItemData.category);
const onConfigureSubmit = (...arg) => {
handleConfigureSubmit(
currentItemData.id,
arg[0],
arg[1],
arg[2],
closeConfigureModal,
);
};
useEffect(() => {
setTitleValue(unitTitle);
dispatch(updateQueryPendingStatus(true));
}, [unitTitle]);
return (
<div className="unit-header-title d-flex align-items-center lead" data-testid="unit-header-title">
{isTitleEditFormOpen ? (
<Form.Group className="m-0">
<Form.Control
ref={(e) => e && e.focus()}
value={titleValue}
name="displayName"
onChange={(e) => setTitleValue(e.target.value)}
aria-label={intl.formatMessage(messages.ariaLabelButtonEdit)}
onBlur={() => handleTitleEditSubmit(titleValue)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
handleTitleEditSubmit(titleValue);
}
}}
/>
</Form.Group>
) : unitTitle}
<IconButton
alt={intl.formatMessage(messages.altButtonEdit)}
className="ml-1 flex-shrink-0 edit-button"
iconAs={EditIcon}
onClick={handleTitleEdit}
/>
{!isUnitPageNewDesignEnabled() && (
<>
<IconButton
alt={intl.formatMessage(messages.altButtonSettings)}
className="flex-shrink-0"
iconAs={SettingsIcon}
onClick={openConfigureModal}
/>
<ConfigureModal
isOpen={isConfigureModalOpen}
onClose={closeConfigureModal}
onConfigureSubmit={onConfigureSubmit}
currentItemData={currentItemData}
isSelfPaced={false}
isXBlockComponent={isXBlockComponent}
/>
</>
)}
</div>
);
};
export default HeaderTitle;

View File

@@ -21,11 +21,6 @@ const messages = defineMessages({
defaultMessage: 'Access to this unit is restricted to: {selectedGroupsLabel}',
description: 'Group visibility accessibility text for Unit',
},
commonVisibilityMessage: {
id: 'course-authoring.course-unit.heading.visibility.common.message',
defaultMessage: 'Access to some content in this unit is restricted to specific groups of learners.',
description: 'The label text of some content restriction in this unit',
},
});
export default messages;

View File

@@ -49,7 +49,7 @@ export const useCourseUnit = ({ courseId, blockId }) => {
const dispatch = useDispatch();
const [searchParams] = useSearchParams();
const { sendMessageToIframe } = useIframe();
const [addComponentTemplateData, setAddComponentTemplateData] = useState({});
const [addComponentTemplateData, setAddComponentTemplateData] = useState(undefined);
const [isMoveModalOpen, openMoveModal, closeMoveModal] = useToggle(false);
const courseUnit = useSelector(getCourseUnitData);

View File

@@ -53,6 +53,46 @@ const messages = defineMessages({
defaultMessage: 'library',
description: 'Text of the link in the alert when the unit is read only because is a library unit',
},
statusBarDraftChangesBadge: {
id: 'course-authoring.course-unit.status-bar.publish-status.draft-changes',
defaultMessage: 'Unpublished changes',
description: 'Text for the Draft Changes Badge in the status bar.',
},
statusBarDiscussionsEnabled: {
id: 'course-authoring.course-unit.status-bar.discussions-enabled',
defaultMessage: 'Discussions Enabled',
description: 'Text for the Discussions enabled Badge in the status bar.',
},
statusBarDraftNeverPublished: {
id: 'course-authoring.course-unit.status-bar.visibility.draft',
defaultMessage: 'Draft (Never Published)',
description: 'Text for the Discussions enabled Badge in the status bar.',
},
statusBarGroupAccessOneGroup: {
id: 'course-authoring.course-unit.status-bar.access.one-group',
defaultMessage: 'Access: {groupName}',
description: 'Text in the status bar when the access for the unit is for one group',
},
statusBarGroupAccessMultipleGroup: {
id: 'course-authoring.course-unit.status-bar.access.multiple-group',
defaultMessage: 'Access: {groupsCount} Groups',
description: 'Text in the status bar when the access for the unit is for one group',
},
statusBarLiveBadge: {
id: 'course-authoring.course-unit.status-bar.visibility.chip',
defaultMessage: 'Live',
description: 'Text for the Live Badge in the status bar.',
},
statusBarStaffOnly: {
id: 'course-authoring.course-unit.status-bar.visibility.staff-only',
defaultMessage: 'Staff Only',
description: 'Text for the Staff Only Badge in the status bar.',
},
statusBarScheduledBadge: {
id: 'course-authoring.course-unit.status-bar.visibility.scheduled',
defaultMessage: 'Scheduled',
description: 'Text for the Upcoming Badge in the status bar.',
},
});
export default messages;

View File

@@ -1,3 +1,5 @@
import { getConfig } from '@edx/frontend-platform';
/**
* Adapts API URL paths to the application's internal URL format based on predefined conditions.
*
@@ -43,3 +45,7 @@ export const subsectionFirstUnitEditUrl = (
const url = `/course/${courseId}/subsection/${subsectionId}`;
return url;
};
export const isUnitPageNewDesignEnabled = () => (
getConfig().ENABLE_UNIT_PAGE_NEW_DESIGN?.toString().toLowerCase() === 'true'
);

View File

@@ -1,8 +1,8 @@
import React from 'react';
import { Alert } from '@openedx/paragon';
interface Props extends React.ComponentPropsWithoutRef<typeof Alert> {
title?: string;
interface Props extends Omit<React.ComponentPropsWithoutRef<typeof Alert>, 'title'> {
title?: string | React.ReactNode;
description?: string | React.ReactNode;
}

View File

@@ -174,6 +174,7 @@ initialize({
ENABLE_VIDEO_UPLOAD_PAGE_LINK_IN_CONTENT_DROPDOWN: process.env.ENABLE_VIDEO_UPLOAD_PAGE_LINK_IN_CONTENT_DROPDOWN || 'false',
ENABLE_CERTIFICATE_PAGE: process.env.ENABLE_CERTIFICATE_PAGE || 'false',
ENABLE_COURSE_IMPORT_IN_LIBRARY: process.env.ENABLE_COURSE_IMPORT_IN_LIBRARY || 'false',
ENABLE_UNIT_PAGE_NEW_DESIGN: process.env.ENABLE_UNIT_PAGE_NEW_DESIGN || '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',