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:
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_UNIT_PAGE_NEW_DESIGN=false
|
||||
ENABLE_COURSE_OUTLINE_NEW_DESIGN=false
|
||||
BBB_LEARN_MORE_URL=''
|
||||
HOTJAR_APP_ID=''
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
98
src/course-unit/header-navigations/HeaderNavigations.tsx
Normal file
98
src/course-unit/header-navigations/HeaderNavigations.tsx
Normal 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;
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
113
src/course-unit/header-title/HeaderTitle.test.tsx
Normal file
113
src/course-unit/header-title/HeaderTitle.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
121
src/course-unit/header-title/HeaderTitle.tsx
Normal file
121
src/course-unit/header-title/HeaderTitle.tsx
Normal 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;
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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'
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user