feat: xblock status component

feat: add custom relative dates flag to state

refactor: add gated status type

refactor: alert style

feat: add status text to units

test: add tests

fix: lint issues

refactor: break up xblock status component

fix: selector for isCustomRelativeDatesActive

fix: prereq default value
This commit is contained in:
Navin Karkera
2024-01-11 20:02:54 +05:30
committed by Kristin Aoki
parent 9c52b8b6c5
commit 2cb907e731
27 changed files with 1181 additions and 57 deletions

View File

@@ -53,6 +53,7 @@ const CourseOutline = ({ courseId }) => {
statusBarData,
courseActions,
sectionsList,
isCustomRelativeDatesActive,
isLoading,
isReIndexShow,
showErrorAlert,
@@ -309,6 +310,8 @@ const CourseOutline = ({ courseId }) => {
section={section}
index={sectionIndex}
canMoveItem={canMoveItem(sections)}
isSelfPaced={statusBarData.isSelfPaced}
isCustomRelativeDatesActive={isCustomRelativeDatesActive}
savingStatus={savingStatus}
onOpenHighlightsModal={handleOpenHighlightsModal}
onOpenPublishModal={openPublishModal}
@@ -332,6 +335,8 @@ const CourseOutline = ({ courseId }) => {
subsection={subsection}
index={subsectionIndex}
canMoveItem={canMoveItem(section.childInfo.children)}
isSelfPaced={statusBarData.isSelfPaced}
isCustomRelativeDatesActive={isCustomRelativeDatesActive}
savingStatus={savingStatus}
onOpenPublishModal={openPublishModal}
onOpenDeleteModal={openDeleteModal}
@@ -356,6 +361,8 @@ const CourseOutline = ({ courseId }) => {
unit={unit}
subsection={subsection}
section={section}
isSelfPaced={statusBarData.isSelfPaced}
isCustomRelativeDatesActive={isCustomRelativeDatesActive}
index={unitIndex}
canMoveItem={canMoveItem(subsection.childInfo.children)}
savingStatus={savingStatus}

View File

@@ -9,3 +9,4 @@
@import "./publish-modal/PublishModal";
@import "./configure-modal/ConfigureModal";
@import "./drag-helper/ConditionalSortableElement";
@import "./xblock-status/XBlockStatus";

View File

@@ -1,7 +1,6 @@
.item-card-header {
display: flex;
align-items: center;
margin-right: -.5rem;
.item-card-header__title-btn {
justify-content: flex-start;

View File

@@ -9,6 +9,10 @@ const messages = defineMessages({
id: 'course-authoring.course-outline.card.status-badge.live',
defaultMessage: 'Live',
},
statusBadgeGated: {
id: 'course-authoring.course-outline.card.status-badge.gated',
defaultMessage: 'Gated',
},
statusBadgePublishedNotLive: {
id: 'course-authoring.course-outline.card.status-badge.published-not-live',
defaultMessage: 'Published not live',

View File

@@ -1,5 +1,6 @@
export const ITEM_BADGE_STATUS = /** @type {const} */ ({
live: 'live',
gated: 'gated',
publishedNotLive: 'published_not_live',
unpublishedChanges: 'unpublished_changes',
staffOnly: 'staff_only',

View File

@@ -7,3 +7,4 @@ export const getCurrentItem = (state) => state.courseOutline.currentItem;
export const getCurrentSection = (state) => state.courseOutline.currentSection;
export const getCurrentSubsection = (state) => state.courseOutline.currentSubsection;
export const getCourseActions = (state) => state.courseOutline.actions;
export const getCustomRelativeDatesActiveFlag = (state) => state.courseOutline.isCustomRelativeDatesActive;

View File

@@ -28,6 +28,7 @@ const slice = createSlice({
videoSharingOptions: VIDEO_SHARING_OPTIONS.perVideo,
},
sectionsList: [],
isCustomRelativeDatesActive: false,
currentSection: {},
currentSubsection: {},
currentItem: {},
@@ -42,6 +43,7 @@ const slice = createSlice({
fetchOutlineIndexSuccess: (state, { payload }) => {
state.outlineIndexData = payload;
state.sectionsList = payload.courseStructure?.childInfo?.children || [];
state.isCustomRelativeDatesActive = payload.isCustomRelativeDatesActive;
},
updateOutlineIndexLoadingStatus: (state, { payload }) => {
state.loadingStatus = {

View File

@@ -1,6 +1,6 @@
import React from 'react';
import PropTypes from 'prop-types';
import { Row } from '@edx/paragon';
import { Col, Row } from '@edx/paragon';
import { SortableItem } from '@edx/frontend-lib-content-components';
const ConditionalSortableElement = ({
@@ -24,9 +24,9 @@ const ConditionalSortableElement = ({
id={id}
componentStyle={style}
>
<div className="extend-margin">
<Col className="extend-margin px-0">
{children}
</div>
</Col>
</SortableItem>
);
}
@@ -36,7 +36,9 @@ const ConditionalSortableElement = ({
style={style}
className="mx-0"
>
{children}
<Col className="px-0">
{children}
</Col>
</Row>
);
};

View File

@@ -1,7 +1,4 @@
.extend-margin {
display: flex;
flex-grow: 1;
.item-children {
margin-right: -2.75rem;
}

View File

@@ -21,6 +21,7 @@ import {
getCurrentItem,
getCurrentSection,
getCurrentSubsection,
getCustomRelativeDatesActiveFlag,
} from './data/selectors';
import {
addNewSectionQuery,
@@ -62,6 +63,7 @@ const useCourseOutline = ({ courseId }) => {
const currentItem = useSelector(getCurrentItem);
const currentSection = useSelector(getCurrentSection);
const currentSubsection = useSelector(getCurrentSubsection);
const isCustomRelativeDatesActive = useSelector(getCustomRelativeDatesActiveFlag);
const [isEnableHighlightsModalOpen, openEnableHighlightsModal, closeEnableHighlightsModal] = useToggle(false);
const [isSectionsExpanded, setSectionsExpanded] = useState(true);
@@ -242,6 +244,7 @@ const useCourseOutline = ({ courseId }) => {
courseActions,
savingStatus,
sectionsList,
isCustomRelativeDatesActive,
isLoading: outlineIndexLoadingStatus === RequestStatus.IN_PROGRESS,
isReIndexShow: Boolean(reindexLink),
showSuccessAlert,

View File

@@ -14,11 +14,14 @@ import CardHeader from '../card-header/CardHeader';
import BaseTitleWithStatusBadge from '../card-header/BaseTitleWithStatusBadge';
import ConditionalSortableElement from '../drag-helper/ConditionalSortableElement';
import TitleButton from '../card-header/TitleButton';
import XBlockStatus from '../xblock-status/XBlockStatus';
import { getItemStatus, getItemStatusBorder, scrollToElement } from '../utils';
import messages from './messages';
const SectionCard = ({
section,
isSelfPaced,
isCustomRelativeDatesActive,
children,
index,
canMoveItem,
@@ -60,7 +63,6 @@ const SectionCard = ({
highlights,
actions: sectionActions,
isHeaderVisible = true,
explanatoryMessage = '',
} = section;
// re-create actions object for customizations
@@ -174,18 +176,24 @@ const SectionCard = ({
/>
)}
<div className="section-card__content" data-testid="section-card__content">
{explanatoryMessage && <p className="text-secondary-400 x-small mb-1">{explanatoryMessage}</p>}
<div className="outline-section__status">
<div className="outline-section__status mb-1">
<Button
className="section-card__highlights"
className="p-0 bg-transparent"
data-destid="section-card-highlights-button"
variant="tertiary"
onClick={handleOpenHighlightsModal}
>
<Badge className="highlights-badge">{highlights.length}</Badge>
<Badge className="mr-1 d-flex justify-content-center align-items-center highlights-badge">
{highlights.length}
</Badge>
<p className="m-0 text-black">{messages.sectionHighlightsBadge.defaultMessage}</p>
</Button>
</div>
<XBlockStatus
isSelfPaced={isSelfPaced}
isCustomRelativeDatesActive={isCustomRelativeDatesActive}
blockData={section}
/>
</div>
{isExpanded && (
<div
@@ -226,7 +234,6 @@ SectionCard.propTypes = {
visibilityState: PropTypes.string.isRequired,
highlights: PropTypes.arrayOf(PropTypes.string).isRequired,
shouldScroll: PropTypes.bool,
explanatoryMessage: PropTypes.string,
actions: PropTypes.shape({
deletable: PropTypes.bool.isRequired,
draggable: PropTypes.bool.isRequired,
@@ -235,6 +242,8 @@ SectionCard.propTypes = {
}).isRequired,
isHeaderVisible: PropTypes.bool,
}).isRequired,
isSelfPaced: PropTypes.bool.isRequired,
isCustomRelativeDatesActive: PropTypes.bool.isRequired,
children: PropTypes.node,
onOpenHighlightsModal: PropTypes.func.isRequired,
onOpenPublishModal: PropTypes.func.isRequired,

View File

@@ -13,26 +13,15 @@
color: $headings-color;
}
.section-card__highlights {
display: flex;
align-items: center;
gap: .5rem;
padding: 0;
background: transparent;
&::before {
display: none;
}
.highlights-badge {
width: 1.5rem;
height: 1.5rem;
border-radius: 1.375rem;
font-size: 1rem;
}
.highlights-badge {
display: flex;
justify-content: center;
align-items: center;
width: 1.75rem;
height: 1.75rem;
border-radius: 1.375rem;
font-size: 1.125rem;
font-weight: 700;
}
.section-card__content {
margin-left: 1.7rem;
}
}

View File

@@ -13,12 +13,15 @@ import CardHeader from '../card-header/CardHeader';
import BaseTitleWithStatusBadge from '../card-header/BaseTitleWithStatusBadge';
import ConditionalSortableElement from '../drag-helper/ConditionalSortableElement';
import TitleButton from '../card-header/TitleButton';
import XBlockStatus from '../xblock-status/XBlockStatus';
import { getItemStatus, getItemStatusBorder, scrollToElement } from '../utils';
import messages from './messages';
const SubsectionCard = ({
section,
subsection,
isSelfPaced,
isCustomRelativeDatesActive,
children,
index,
canMoveItem,
@@ -140,26 +143,35 @@ const SubsectionCard = ({
>
<div className="subsection-card" data-testid="subsection-card" ref={currentRef}>
{isHeaderVisible && (
<CardHeader
title={displayName}
status={subsectionStatus}
hasChanges={hasChanges}
onClickMenuButton={handleClickMenuButton}
onClickPublish={onOpenPublishModal}
onClickEdit={openForm}
onClickDelete={onOpenDeleteModal}
onClickMoveUp={handleSubsectionMoveUp}
onClickMoveDown={handleSubsectionMoveDown}
onClickConfigure={onOpenConfigureModal}
isFormOpen={isFormOpen}
closeForm={closeForm}
onEditSubmit={handleEditSubmit}
isDisabledEditField={savingStatus === RequestStatus.IN_PROGRESS}
onClickDuplicate={onDuplicateSubmit}
titleComponent={titleComponent}
namePrefix={namePrefix}
actions={actions}
/>
<>
<CardHeader
title={displayName}
status={subsectionStatus}
hasChanges={hasChanges}
onClickMenuButton={handleClickMenuButton}
onClickPublish={onOpenPublishModal}
onClickEdit={openForm}
onClickDelete={onOpenDeleteModal}
onClickMoveUp={handleSubsectionMoveUp}
onClickMoveDown={handleSubsectionMoveDown}
onClickConfigure={onOpenConfigureModal}
isFormOpen={isFormOpen}
closeForm={closeForm}
onEditSubmit={handleEditSubmit}
isDisabledEditField={savingStatus === RequestStatus.IN_PROGRESS}
onClickDuplicate={onDuplicateSubmit}
titleComponent={titleComponent}
namePrefix={namePrefix}
actions={actions}
/>
<div className="subsection-card__content item-children" data-testid="subsection-card__content">
<XBlockStatus
isSelfPaced={isSelfPaced}
isCustomRelativeDatesActive={isCustomRelativeDatesActive}
blockData={subsection}
/>
</div>
</>
)}
{isExpanded && (
<div
@@ -215,6 +227,8 @@ SubsectionCard.propTypes = {
isHeaderVisible: PropTypes.bool,
}).isRequired,
children: PropTypes.node,
isSelfPaced: PropTypes.bool.isRequired,
isCustomRelativeDatesActive: PropTypes.bool.isRequired,
onOpenPublishModal: PropTypes.func.isRequired,
onEditSubmit: PropTypes.func.isRequired,
savingStatus: PropTypes.string.isRequired,

View File

@@ -16,4 +16,8 @@
line-height: $headings-line-height;
color: $headings-color;
}
.subsection-card__content {
margin-left: 1.7rem;
}
}

View File

@@ -9,12 +9,15 @@ import CardHeader from '../card-header/CardHeader';
import BaseTitleWithStatusBadge from '../card-header/BaseTitleWithStatusBadge';
import ConditionalSortableElement from '../drag-helper/ConditionalSortableElement';
import TitleLink from '../card-header/TitleLink';
import XBlockStatus from '../xblock-status/XBlockStatus';
import { getItemStatus, getItemStatusBorder, scrollToElement } from '../utils';
const UnitCard = ({
unit,
subsection,
section,
isSelfPaced,
isCustomRelativeDatesActive,
index,
canMoveItem,
onOpenPublishModal,
@@ -146,6 +149,13 @@ const UnitCard = ({
namePrefix={namePrefix}
actions={actions}
/>
<div className="unit-card__content item-children" data-testid="unit-card__content">
<XBlockStatus
isSelfPaced={isSelfPaced}
isCustomRelativeDatesActive={isCustomRelativeDatesActive}
blockData={unit}
/>
</div>
</div>
</ConditionalSortableElement>
);
@@ -193,6 +203,8 @@ UnitCard.propTypes = {
index: PropTypes.number.isRequired,
canMoveItem: PropTypes.func.isRequired,
onOrderChange: PropTypes.func.isRequired,
isSelfPaced: PropTypes.bool.isRequired,
isCustomRelativeDatesActive: PropTypes.bool.isRequired,
};
export default UnitCard;

View File

@@ -1,10 +1,6 @@
.unit-card {
flex-grow: 1;
.unit-card__content {
margin: $spacer;
}
.item-card-header__badge-status {
background: $light-100;
}

View File

@@ -21,6 +21,8 @@ const getItemStatus = ({
switch (true) {
case visibilityState === VisibilityTypes.STAFF_ONLY:
return ITEM_BADGE_STATUS.staffOnly;
case visibilityState === VisibilityTypes.GATED:
return ITEM_BADGE_STATUS.gated;
case visibilityState === VisibilityTypes.LIVE:
return ITEM_BADGE_STATUS.live;
case published && !hasChanges:
@@ -42,6 +44,11 @@ const getItemStatus = ({
*/
const getItemStatusBadgeContent = (status, messages, intl) => {
switch (status) {
case ITEM_BADGE_STATUS.gated:
return {
badgeTitle: intl.formatMessage(messages.statusBadgeGated),
badgeIcon: LockIcon,
};
case ITEM_BADGE_STATUS.live:
return {
badgeTitle: intl.formatMessage(messages.statusBadgeLive),
@@ -92,6 +99,10 @@ const getItemStatusBorder = (status) => {
return {
borderLeft: '5px solid #0D7D4D',
};
case ITEM_BADGE_STATUS.gated:
return {
borderLeft: '5px solid #000000',
};
case ITEM_BADGE_STATUS.staffOnly:
return {
borderLeft: '5px solid #000000',

View File

@@ -0,0 +1,48 @@
import React from 'react';
import PropTypes from 'prop-types';
import { useIntl } from '@edx/frontend-platform/i18n';
import { Alert } from '@edx/paragon';
import {
WarningFilled as WarningIcon,
} from '@edx/paragon/icons';
import messages from './messages';
const GradingPolicyAlert = ({
graded,
gradingType,
courseGraders,
}) => {
const intl = useIntl();
let gradingPolicyMismatch = false;
if (graded) {
if (gradingType) {
gradingPolicyMismatch = (
courseGraders.filter((cg) => cg.toLowerCase() === gradingType.toLowerCase())
).length === 0;
}
}
if (gradingPolicyMismatch) {
return (
<Alert className="mt-2 grading-mismatch-alert" variant="warning" icon={WarningIcon}>
{intl.formatMessage(messages.gradingPolicyMismatchText, { gradingType })}
</Alert>
);
}
return null;
};
GradingPolicyAlert.defaultProps = {
graded: false,
gradingType: '',
};
GradingPolicyAlert.propTypes = {
graded: PropTypes.bool,
gradingType: PropTypes.string,
courseGraders: PropTypes.arrayOf(PropTypes.string.isRequired).isRequired,
};
export default GradingPolicyAlert;

View File

@@ -0,0 +1,134 @@
import React from 'react';
import PropTypes from 'prop-types';
import { useIntl } from '@edx/frontend-platform/i18n';
import { Icon } from '@edx/paragon';
import {
Check as CheckIcon,
CalendarMonth as CalendarIcon,
} from '@edx/paragon/icons';
import messages from './messages';
const GradingTypeAndDueDate = ({
isSelfPaced,
isInstructorPaced,
isCustomRelativeDatesActive,
isTimeLimited,
isProctoredExam,
isOnboardingExam,
isPracticeExam,
graded,
gradingType,
dueDate,
relativeWeeksDue,
}) => {
const intl = useIntl();
const showRelativeWeeks = isSelfPaced && isCustomRelativeDatesActive && relativeWeeksDue;
let examValue = '';
if (isProctoredExam) {
if (isOnboardingExam) {
examValue = messages.onboardingExam;
} else if (isPracticeExam) {
examValue = messages.practiceProctoredExam;
} else {
examValue = messages.proctoredExam;
}
} else {
examValue = messages.timedExam;
}
const gradingTypeDiv = () => (
<div className="d-flex align-items-center mr-1" data-testid="grading-type-div">
<span className="sr-only status-grading-label">
{intl.formatMessage(messages.gradedAsScreenReaderLabel)}
</span>
<Icon className="mr-1" size="sm" src={CheckIcon} />
<span className="status-grading-value">
{gradingType || intl.formatMessage(messages.ungradedText)}
</span>
</div>
);
const dueDateDiv = () => {
if (dueDate && isInstructorPaced) {
return (
<div className="status-grading-date" data-testid="due-date-div">
{intl.formatMessage(messages.dueLabel)} {dueDate}
</div>
);
}
return null;
};
const selfPacedRelativeDueWeeksDiv = () => (
<div className="d-flex align-items-center" data-testid="self-paced-relative-due-weeks-div">
<Icon className="mr-1" size="sm" src={CalendarIcon} />
<span className="status-custom-grading-date">
{intl.formatMessage(messages.customDueDateLabel, { relativeWeeksDue })}
</span>
</div>
);
if (isTimeLimited) {
return (
<>
<div className="d-flex align-items-center">
{gradingTypeDiv()} -
<span className="sr-only">{intl.formatMessage(examValue)}</span>
<span className="mx-2" data-testid="exam-value-span">
{intl.formatMessage(examValue)}
</span>
{dueDateDiv()}
</div>
{showRelativeWeeks && (selfPacedRelativeDueWeeksDiv())}
</>
);
} if ((dueDate && !isSelfPaced) || graded) {
return (
<>
<div className="d-flex align-items-center">
{gradingTypeDiv()}
{dueDateDiv()}
</div>
{showRelativeWeeks && (selfPacedRelativeDueWeeksDiv())}
</>
);
} if (showRelativeWeeks) {
return (
<>
{gradingTypeDiv()}
{selfPacedRelativeDueWeeksDiv()}
</>
);
}
return null;
};
GradingTypeAndDueDate.defaultProps = {
isCustomRelativeDatesActive: false,
isTimeLimited: false,
isProctoredExam: false,
isOnboardingExam: false,
isPracticeExam: false,
graded: false,
gradingType: '',
dueDate: '',
relativeWeeksDue: null,
};
GradingTypeAndDueDate.propTypes = {
isInstructorPaced: PropTypes.bool.isRequired,
isSelfPaced: PropTypes.bool.isRequired,
isCustomRelativeDatesActive: PropTypes.bool,
isTimeLimited: PropTypes.bool,
isProctoredExam: PropTypes.bool,
isOnboardingExam: PropTypes.bool,
isPracticeExam: PropTypes.bool,
graded: PropTypes.bool,
gradingType: PropTypes.string,
dueDate: PropTypes.string,
relativeWeeksDue: PropTypes.number,
};
export default GradingTypeAndDueDate;

View File

@@ -0,0 +1,29 @@
import React from 'react';
import PropTypes from 'prop-types';
import { useIntl } from '@edx/frontend-platform/i18n';
import { Icon } from '@edx/paragon';
import {
VisibilityOff as HideIcon,
} from '@edx/paragon/icons';
import messages from './messages';
const HideAfterDueMessage = ({ isSelfPaced }) => {
const intl = useIntl();
return (
<div className="d-flex align-items-center" data-testid="hide-after-due-message">
<Icon className="mr-1" size="sm" src={HideIcon} />
<span className="status-hide-after-due-value">
{isSelfPaced
? intl.formatMessage(messages.hiddenAfterEndDate)
: intl.formatMessage(messages.hiddenAfterDueDate)}
</span>
</div>
);
};
HideAfterDueMessage.propTypes = {
isSelfPaced: PropTypes.bool.isRequired,
};
export default HideAfterDueMessage;

View File

@@ -0,0 +1,65 @@
import React from 'react';
import PropTypes from 'prop-types';
import { useIntl } from '@edx/frontend-platform/i18n';
import { Icon } from '@edx/paragon';
import {
AccessTime as ClockIcon,
} from '@edx/paragon/icons';
import messages from './messages';
const ReleaseStatus = ({
isInstructorPaced,
explanatoryMessage,
releaseDate,
releasedToStudents,
}) => {
const intl = useIntl();
const explanatoryMessageDiv = () => (
<span data-testid="explanatory-message-span">
{explanatoryMessage}
</span>
);
let releaseLabel = messages.unscheduledLabel;
if (releasedToStudents) {
releaseLabel = messages.releasedLabel;
} else if (releaseDate) {
releaseLabel = messages.scheduledLabel;
}
const releaseStatusDiv = () => (
<div className="d-flex align-items-center" data-testid="release-status-div">
<span className="sr-only status-release-label">
{intl.formatMessage(messages.releaseStatusScreenReaderTitle)}
</span>
<Icon className="mr-1" size="sm" src={ClockIcon} />
{intl.formatMessage(releaseLabel)}
{releaseDate && releaseDate}
</div>
);
if (explanatoryMessage) {
return explanatoryMessageDiv();
}
if (isInstructorPaced) {
return releaseStatusDiv();
}
return null;
};
ReleaseStatus.defaultProps = {
explanatoryMessage: '',
};
ReleaseStatus.propTypes = {
isInstructorPaced: PropTypes.bool.isRequired,
explanatoryMessage: PropTypes.string,
releaseDate: PropTypes.string.isRequired,
releasedToStudents: PropTypes.bool.isRequired,
};
export default ReleaseStatus;

View File

@@ -0,0 +1,88 @@
import React from 'react';
import PropTypes from 'prop-types';
import { useIntl } from '@edx/frontend-platform/i18n';
import { Icon } from '@edx/paragon';
import {
Lock as LockIcon,
Groups as GroupsIcon,
} from '@edx/paragon/icons';
import messages from './messages';
const StatusMessages = ({
isVertical,
staffOnlyMessage,
prereq,
prereqs,
userPartitionInfo,
hasPartitionGroupComponents,
}) => {
const intl = useIntl();
const statusMessages = [];
if (prereq) {
let prereqDisplayName = '';
prereqs.forEach((block) => {
if (block.blockUsageKey === prereq) {
prereqDisplayName = block.blockDisplayName;
}
});
statusMessages.push({
icon: LockIcon,
text: intl.formatMessage(messages.prerequisiteLabel, { prereqDisplayName }),
});
}
if (!staffOnlyMessage && isVertical) {
const { selectedPartitionIndex, selectedGroupsLabel } = userPartitionInfo;
if (selectedPartitionIndex !== -1 && !Number.isNaN(selectedPartitionIndex)) {
statusMessages.push({
icon: GroupsIcon,
text: intl.formatMessage(messages.restrictedUnitAccess, { selectedGroupsLabel }),
});
} else if (hasPartitionGroupComponents) {
statusMessages.push({
icon: GroupsIcon,
text: intl.formatMessage(messages.restrictedUnitAccessToSomeContent),
});
}
}
if (statusMessages.length > 0) {
return (
<div className="border-top border-light mt-2 text-dark" data-testid="status-messages-div">
{statusMessages.map(({ icon, text }) => (
<div key={text} className="d-flex align-items-center pt-1">
<Icon className="mr-1" size="sm" src={icon} />
{text}
</div>
))}
</div>
);
}
return null;
};
StatusMessages.defaultProps = {
staffOnlyMessage: false,
prereq: '',
prereqs: [],
userPartitionInfo: {},
};
StatusMessages.propTypes = {
isVertical: PropTypes.bool.isRequired,
staffOnlyMessage: PropTypes.bool,
prereq: PropTypes.string,
prereqs: PropTypes.arrayOf(PropTypes.shape({
blockUsageKey: PropTypes.string.isRequired,
blockDisplayName: PropTypes.string.isRequired,
})),
userPartitionInfo: PropTypes.shape({
selectedPartitionIndex: PropTypes.number.isRequired,
selectedGroupsLabel: PropTypes.string.isRequired,
}),
hasPartitionGroupComponents: PropTypes.bool.isRequired,
};
export default StatusMessages;

View File

@@ -0,0 +1,122 @@
import React from 'react';
import PropTypes from 'prop-types';
import { COURSE_BLOCK_NAMES } from '../constants';
import ReleaseStatus from './ReleaseStatus';
import GradingPolicyAlert from './GradingPolicyAlert';
import GradingTypeAndDueDate from './GradingTypeAndDueDate';
import StatusMessages from './StatusMessages';
import HideAfterDueMessage from './HideAfterDueMessage';
const XBlockStatus = ({
isSelfPaced,
isCustomRelativeDatesActive,
blockData,
}) => {
const {
category,
explanatoryMessage,
releasedToStudents,
releaseDate,
isProctoredExam,
isOnboardingExam,
isPracticeExam,
prereq,
prereqs,
staffOnlyMessage,
userPartitionInfo,
hasPartitionGroupComponents,
format: gradingType,
dueDate,
relativeWeeksDue,
isTimeLimited,
graded,
courseGraders,
hideAfterDue,
} = blockData;
const isInstructorPaced = !isSelfPaced;
const isVertical = category === COURSE_BLOCK_NAMES.vertical.id;
return (
<div className="text-secondary-400 x-small mb-1">
{!isVertical && (
<ReleaseStatus
isInstructorPaced={isInstructorPaced}
explanatoryMessage={explanatoryMessage}
releaseDate={releaseDate}
releasedToStudents={releasedToStudents}
/>
)}
{!isVertical && (
<GradingTypeAndDueDate
isSelfPaced={isSelfPaced}
isInstructorPaced={isInstructorPaced}
isCustomRelativeDatesActive={isCustomRelativeDatesActive}
isTimeLimited={isTimeLimited}
isProctoredExam={isProctoredExam}
isOnboardingExam={isOnboardingExam}
isPracticeExam={isPracticeExam}
graded={graded}
gradingType={gradingType}
dueDate={dueDate}
relativeWeeksDue={relativeWeeksDue}
/>
)}
{hideAfterDue && (
<HideAfterDueMessage isSelfPaced={isSelfPaced} />
)}
<StatusMessages
isVertical={isVertical}
staffOnlyMessage={staffOnlyMessage}
prereq={prereq}
prereqs={prereqs}
userPartitionInfo={userPartitionInfo}
hasPartitionGroupComponents={hasPartitionGroupComponents}
/>
<GradingPolicyAlert
graded={graded}
gradingType={gradingType}
courseGraders={courseGraders}
/>
</div>
);
};
XBlockStatus.defaultProps = {
isCustomRelativeDatesActive: false,
};
XBlockStatus.propTypes = {
isSelfPaced: PropTypes.bool.isRequired,
isCustomRelativeDatesActive: PropTypes.bool,
blockData: PropTypes.shape({
category: PropTypes.string.isRequired,
explanatoryMessage: PropTypes.string,
releasedToStudents: PropTypes.bool.isRequired,
releaseDate: PropTypes.string.isRequired,
isProctoredExam: PropTypes.bool,
isOnboardingExam: PropTypes.bool,
isPracticeExam: PropTypes.bool,
prereq: PropTypes.string,
prereqs: PropTypes.arrayOf(PropTypes.shape({
blockUsageKey: PropTypes.string.isRequired,
blockDisplayName: PropTypes.string.isRequired,
})),
staffOnlyMessage: PropTypes.bool,
userPartitionInfo: PropTypes.shape({
selectedPartitionIndex: PropTypes.number.isRequired,
selectedGroupsLabel: PropTypes.string.isRequired,
}),
hasPartitionGroupComponents: PropTypes.bool.isRequired,
format: PropTypes.string,
dueDate: PropTypes.string,
relativeWeeksDue: PropTypes.number,
isTimeLimited: PropTypes.bool,
graded: PropTypes.bool,
courseGraders: PropTypes.arrayOf(PropTypes.string.isRequired).isRequired,
hideAfterDue: PropTypes.bool,
}).isRequired,
};
export default XBlockStatus;

View File

@@ -0,0 +1,4 @@
.grading-mismatch-alert {
font-size: 14px;
font-weight: 400;
}

View File

@@ -0,0 +1,503 @@
import React from 'react';
import { render } from '@testing-library/react';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import { AppProvider } from '@edx/frontend-platform/react';
import { initializeMockApp } from '@edx/frontend-platform';
import MockAdapter from 'axios-mock-adapter';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import initializeStore from '../../store';
import XBlockStatus from './XBlockStatus';
import messages from './messages';
// eslint-disable-next-line no-unused-vars
let axiosMock;
let store;
jest.mock('@edx/frontend-platform/i18n', () => ({
...jest.requireActual('@edx/frontend-platform/i18n'),
useIntl: () => ({
formatMessage: (message) => message.defaultMessage,
}),
}));
const section = {
id: '123',
displayName: 'Section Name',
published: true,
visibilityState: 'live',
hasChanges: false,
highlights: ['highlight 1', 'highlight 2'],
category: 'chapter',
explanatoryMessage: '',
releasedToStudents: true,
releaseDate: 'Feb 05, 2013 at 01:00 UTC',
isProctoredExam: false,
isOnboardingExam: false,
isPracticeExam: false,
staffOnlyMessage: false,
userPartitionInfo: {
selectedPartitionIndex: -1,
selectedGroupsLabel: '',
},
hasPartitionGroupComponents: false,
format: 'Homework',
dueDate: 'Dec 28, 2023 at 22:00 UTC',
isTimeLimited: true,
graded: true,
courseGraders: ['Homework'],
hideAfterDue: true,
};
const renderComponent = (props) => render(
<AppProvider store={store}>
<IntlProvider locale="en">
<XBlockStatus
isSelfPaced={false}
isCustomRelativeDatesActive={false}
{...props}
/>
</IntlProvider>,
</AppProvider>,
);
describe('<XBlockStatus /> for Instructor paced Section', () => {
beforeEach(() => {
initializeMockApp({
authenticatedUser: {
userId: 3,
username: 'abc123',
administrator: true,
roles: [],
},
});
store = initializeStore();
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
});
it('render XBlockStatus with explanatoryMessage', () => {
const { queryByTestId } = renderComponent({
blockData: {
...section,
explanatoryMessage: 'some explanatory message',
},
});
expect(queryByTestId('explanatory-message-span')).toBeInTheDocument();
// when explanatory message is displayed, release date should not be visible
expect(queryByTestId('release-status-div')).not.toBeInTheDocument();
});
it('renders XBlockStatus with release status, grading type, due date etc.', () => {
const { queryByTestId } = renderComponent({ blockData: section });
expect(queryByTestId('explanatory-message-span')).not.toBeInTheDocument();
// when explanatory message is not displayed, release date should be visible
const releaseStatusDiv = queryByTestId('release-status-div');
expect(releaseStatusDiv).toBeInTheDocument();
expect(releaseStatusDiv).toHaveTextContent(
`${messages.releasedLabel.defaultMessage}${section.releaseDate}`,
);
// check grading type
const gradingTypeDiv = queryByTestId('grading-type-div');
expect(gradingTypeDiv).toBeInTheDocument();
expect(gradingTypeDiv).toHaveTextContent(section.format);
// check exam value label
const examValue = queryByTestId('exam-value-span');
expect(examValue).toBeInTheDocument();
expect(examValue).toHaveTextContent(messages.timedExam.defaultMessage);
// check due date div
const dueDateDiv = queryByTestId('due-date-div');
expect(dueDateDiv).toBeInTheDocument();
expect(dueDateDiv).toHaveTextContent(
`${messages.dueLabel.defaultMessage} ${section.dueDate}`,
);
// self paced weeks should not be visible as
// isSelfPaced is false as well as isCustomRelativeDatesActive is false
expect(queryByTestId('self-paced-relative-due-weeks-div')).not.toBeInTheDocument();
// check hide after due date message
const hideAfterDueMessage = queryByTestId('hide-after-due-message');
expect(hideAfterDueMessage).toBeInTheDocument();
expect(hideAfterDueMessage).toHaveTextContent(messages.hiddenAfterDueDate.defaultMessage);
// check status messages
const statusDiv = queryByTestId('status-messages-div');
expect(statusDiv).not.toBeInTheDocument();
});
});
describe('<XBlockStatus /> for self paced Section', () => {
beforeEach(() => {
initializeMockApp({
authenticatedUser: {
userId: 3,
username: 'abc123',
administrator: true,
roles: [],
},
});
store = initializeStore();
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
});
it('renders XBlockStatus with grading type, due weeks etc.', () => {
const { queryByTestId } = renderComponent({
isSelfPaced: true,
isCustomRelativeDatesActive: true,
blockData: {
...section,
relativeWeeksDue: 2,
},
});
// both explanatoryMessage & releaseStatusDiv should not be visible
expect(queryByTestId('explanatory-message-span')).not.toBeInTheDocument();
expect(queryByTestId('release-status-div')).not.toBeInTheDocument();
// check grading type
const gradingTypeDiv = queryByTestId('grading-type-div');
expect(gradingTypeDiv).toBeInTheDocument();
expect(gradingTypeDiv).toHaveTextContent(section.format);
// due date should not be visible for self paced courses.
expect(queryByTestId('due-date-div')).not.toBeInTheDocument();
// check selfPacedRelativeDueWeeksDiv
const selfPacedRelativeDueWeeksDiv = queryByTestId('self-paced-relative-due-weeks-div');
expect(selfPacedRelativeDueWeeksDiv).toBeInTheDocument();
expect(selfPacedRelativeDueWeeksDiv).toHaveTextContent(
messages.customDueDateLabel.defaultMessage,
);
// check hide after due date message
const hideAfterDueMessage = queryByTestId('hide-after-due-message');
expect(hideAfterDueMessage).toBeInTheDocument();
expect(hideAfterDueMessage).toHaveTextContent(messages.hiddenAfterEndDate.defaultMessage);
// check status messages
expect(queryByTestId('status-messages-div')).not.toBeInTheDocument();
});
it('renders XBlockStatus with grading mismatch alert', () => {
const { queryByText } = renderComponent({
blockData: {
...section,
format: 'Fun',
},
});
// check alert
const alert = queryByText(messages.gradingPolicyMismatchText.defaultMessage);
expect(alert).toBeInTheDocument();
});
});
const subsection = {
id: '123',
displayName: 'Subsection Name',
published: true,
visibilityState: 'live',
hasChanges: false,
highlights: ['highlight 1', 'highlight 2'],
category: 'sequential',
explanatoryMessage: '',
releasedToStudents: false,
releaseDate: 'Feb 05, 2025 at 01:00 UTC',
isProctoredExam: false,
isOnboardingExam: false,
isPracticeExam: false,
prereq: 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@dbe8fc0',
prereqs: [
{
blockDisplayName: 'Find your study buddy',
blockUsageKey: 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@dbe8fc0',
},
{
blockDisplayName: 'Something else',
blockUsageKey: 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@sdafyrb',
},
],
staffOnlyMessage: false,
userPartitionInfo: {
selectedPartitionIndex: -1,
selectedGroupsLabel: '',
},
hasPartitionGroupComponents: false,
format: 'Homework',
dueDate: 'Dec 28, 2023 at 22:00 UTC',
isTimeLimited: true,
graded: true,
courseGraders: ['Homework'],
hideAfterDue: true,
};
describe('<XBlockStatus /> for Instructor paced Subsection', () => {
beforeEach(() => {
initializeMockApp({
authenticatedUser: {
userId: 3,
username: 'abc123',
administrator: true,
roles: [],
},
});
store = initializeStore();
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
});
it('renders XBlockStatus with release status, grading type, due date etc.', () => {
const { queryByTestId } = renderComponent({ blockData: subsection });
expect(queryByTestId('explanatory-message-span')).not.toBeInTheDocument();
// when explanatory message is not displayed, release date should be visible
const releaseStatusDiv = queryByTestId('release-status-div');
expect(releaseStatusDiv).toBeInTheDocument();
expect(releaseStatusDiv).toHaveTextContent(
`${messages.scheduledLabel.defaultMessage}${subsection.releaseDate}`,
);
// check grading type
const gradingTypeDiv = queryByTestId('grading-type-div');
expect(gradingTypeDiv).toBeInTheDocument();
expect(gradingTypeDiv).toHaveTextContent(subsection.format);
// check exam value label
const examValue = queryByTestId('exam-value-span');
expect(examValue).toBeInTheDocument();
expect(examValue).toHaveTextContent(messages.timedExam.defaultMessage);
// check due date div
const dueDateDiv = queryByTestId('due-date-div');
expect(dueDateDiv).toBeInTheDocument();
expect(dueDateDiv).toHaveTextContent(
`${messages.dueLabel.defaultMessage} ${subsection.dueDate}`,
);
// self paced weeks should not be visible as
// isSelfPaced is false as well as isCustomRelativeDatesActive is false
expect(queryByTestId('self-paced-relative-due-weeks-div')).not.toBeInTheDocument();
// check hide after due date message
const hideAfterDueMessage = queryByTestId('hide-after-due-message');
expect(hideAfterDueMessage).toBeInTheDocument();
expect(hideAfterDueMessage).toHaveTextContent(messages.hiddenAfterDueDate.defaultMessage);
// check status messages
const statusDiv = queryByTestId('status-messages-div');
expect(statusDiv).toBeInTheDocument();
expect(statusDiv).toHaveTextContent(messages.prerequisiteLabel.defaultMessage);
});
it('renders XBlockStatus with proctored exam info', () => {
const { queryByTestId } = renderComponent({
blockData: {
...subsection,
isProctoredExam: true,
isOnboardingExam: false,
isPracticeExam: false,
},
});
// check exam value label
const examValue = queryByTestId('exam-value-span');
expect(examValue).toBeInTheDocument();
expect(examValue).toHaveTextContent(messages.proctoredExam.defaultMessage);
});
it('renders XBlockStatus with practice proctored exam info', () => {
const { queryByTestId } = renderComponent({
blockData: {
...subsection,
isProctoredExam: true,
isOnboardingExam: false,
isPracticeExam: true,
},
});
// check exam value label
const examValue = queryByTestId('exam-value-span');
expect(examValue).toBeInTheDocument();
expect(examValue).toHaveTextContent(messages.practiceProctoredExam.defaultMessage);
});
it('renders XBlockStatus with onboarding exam info', () => {
const { queryByTestId } = renderComponent({
blockData: {
...subsection,
isProctoredExam: true,
isOnboardingExam: true,
isPracticeExam: false,
},
});
// check exam value label
const examValue = queryByTestId('exam-value-span');
expect(examValue).toBeInTheDocument();
expect(examValue).toHaveTextContent(messages.onboardingExam.defaultMessage);
});
it('renders XBlockStatus correctly for graded but not time limited subsection', () => {
const { queryByTestId } = renderComponent({
blockData: {
...subsection,
isTimeLimited: false,
graded: true,
},
});
// check grading type
const gradingTypeDiv = queryByTestId('grading-type-div');
expect(gradingTypeDiv).toBeInTheDocument();
expect(gradingTypeDiv).toHaveTextContent(subsection.format);
// exam value label should not be visible
expect(queryByTestId('exam-value-span')).not.toBeInTheDocument();
// check due date div
const dueDateDiv = queryByTestId('due-date-div');
expect(dueDateDiv).toBeInTheDocument();
expect(dueDateDiv).toHaveTextContent(
`${messages.dueLabel.defaultMessage} ${subsection.dueDate}`,
);
// self paced weeks should not be visible as
// isSelfPaced is false as well as isCustomRelativeDatesActive is false
expect(queryByTestId('self-paced-relative-due-weeks-div')).not.toBeInTheDocument();
});
});
describe('<XBlockStatus /> for self paced Subsection', () => {
beforeEach(() => {
initializeMockApp({
authenticatedUser: {
userId: 3,
username: 'abc123',
administrator: true,
roles: [],
},
});
store = initializeStore();
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
});
it('renders XBlockStatus with grading type, due weeks etc.', () => {
const { queryByTestId } = renderComponent({
isSelfPaced: true,
isCustomRelativeDatesActive: true,
blockData: {
...subsection,
relativeWeeksDue: 2,
},
});
// both explanatoryMessage & releaseStatusDiv should not be visible
expect(queryByTestId('explanatory-message-span')).not.toBeInTheDocument();
expect(queryByTestId('release-status-div')).not.toBeInTheDocument();
// check grading type
const gradingTypeDiv = queryByTestId('grading-type-div');
expect(gradingTypeDiv).toBeInTheDocument();
expect(gradingTypeDiv).toHaveTextContent(subsection.format);
// due date should not be visible for self paced courses.
expect(queryByTestId('due-date-div')).not.toBeInTheDocument();
// check selfPacedRelativeDueWeeksDiv
const selfPacedRelativeDueWeeksDiv = queryByTestId('self-paced-relative-due-weeks-div');
expect(selfPacedRelativeDueWeeksDiv).toBeInTheDocument();
expect(selfPacedRelativeDueWeeksDiv).toHaveTextContent(
messages.customDueDateLabel.defaultMessage,
);
// check hide after due date message
const hideAfterDueMessage = queryByTestId('hide-after-due-message');
expect(hideAfterDueMessage).toBeInTheDocument();
expect(hideAfterDueMessage).toHaveTextContent(messages.hiddenAfterEndDate.defaultMessage);
// check status messages
const statusDiv = queryByTestId('status-messages-div');
expect(statusDiv).toBeInTheDocument();
expect(statusDiv).toHaveTextContent(messages.prerequisiteLabel.defaultMessage);
});
});
const unit = {
id: '123',
displayName: 'Unit Name',
published: true,
visibilityState: 'live',
hasChanges: false,
highlights: ['highlight 1', 'highlight 2'],
category: 'vertical',
explanatoryMessage: '',
releasedToStudents: true,
releaseDate: 'Feb 05, 2013 at 01:00 UTC',
isProctoredExam: false,
isOnboardingExam: false,
isPracticeExam: false,
staffOnlyMessage: false,
userPartitionInfo: {
selectedPartitionIndex: 1,
selectedGroupsLabel: 'Some label',
},
hasPartitionGroupComponents: false,
format: 'Homework',
dueDate: 'Dec 28, 2023 at 22:00 UTC',
isTimeLimited: true,
graded: true,
courseGraders: ['Homework'],
};
describe('<XBlockStatus /> for unit', () => {
beforeEach(() => {
initializeMockApp({
authenticatedUser: {
userId: 3,
username: 'abc123',
administrator: true,
roles: [],
},
});
store = initializeStore();
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
});
it('renders XBlockStatus with status messages', () => {
const { queryByTestId } = renderComponent({ blockData: unit });
expect(queryByTestId('explanatory-message-span')).not.toBeInTheDocument();
expect(queryByTestId('release-status-div')).not.toBeInTheDocument();
// grading type should not be visible
expect(queryByTestId('grading-type-div')).not.toBeInTheDocument();
// due date should not be visible
expect(queryByTestId('due-date-div')).not.toBeInTheDocument();
// self paced weeks should not be visible for units
expect(queryByTestId('self-paced-relative-due-weeks-div')).not.toBeInTheDocument();
// check hide after due date message
// hide after due date message should not be visible as the flag is set to false
expect(queryByTestId('hide-after-due-message')).not.toBeInTheDocument();
// check status messages for partition info
const statusDiv = queryByTestId('status-messages-div');
expect(statusDiv).toBeInTheDocument();
expect(statusDiv).toHaveTextContent(messages.restrictedUnitAccess.defaultMessage);
});
it('renders XBlockStatus with status messages', () => {
const { queryByTestId } = renderComponent({
blockData: {
...unit,
hasPartitionGroupComponents: true,
userPartitionInfo: {
selectedPartitionIndex: -1,
selectedGroupsLabel: '',
},
},
});
// check status messages for partition info
const statusDiv = queryByTestId('status-messages-div');
expect(statusDiv).toBeInTheDocument();
expect(statusDiv).toHaveTextContent(messages.restrictedUnitAccessToSomeContent.defaultMessage);
});
});

View File

@@ -0,0 +1,78 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
unscheduledLabel: {
id: 'course-authoring.course-outline.xblock-status.unscheduled.label',
defaultMessage: 'Unscheduled',
},
releasedLabel: {
id: 'course-authoring.course-outline.xblock-status.released.label',
defaultMessage: 'Released: ',
},
scheduledLabel: {
id: 'course-authoring.course-outline.xblock-status.scheduled.label',
defaultMessage: 'Scheduled: ',
},
onboardingExam: {
id: 'course-authoring.course-outline.xblock-status.onboardingExam.value',
defaultMessage: 'Onboarding Exam',
},
practiceProctoredExam: {
id: 'course-authoring.course-outline.xblock-status.practiceProctoredExam.value',
defaultMessage: 'Practice proctored Exam',
},
proctoredExam: {
id: 'course-authoring.course-outline.xblock-status.proctoredExam.value',
defaultMessage: 'Proctored Exam',
},
timedExam: {
id: 'course-authoring.course-outline.xblock-status.timedExam.value',
defaultMessage: 'Timed Exam',
},
releaseStatusScreenReaderTitle: {
id: 'course-authoring.course-outline.xblock-status.releaseStatusScreenReader.title',
defaultMessage: 'Release Status: ',
},
gradedAsScreenReaderLabel: {
id: 'course-authoring.course-outline.xblock-status.gradedAsScreenReader.label',
defaultMessage: 'Graded as: ',
},
ungradedText: {
id: 'course-authoring.course-outline.xblock-status.ungraded.text',
defaultMessage: 'Ungraded',
},
dueLabel: {
id: 'course-authoring.course-outline.xblock-status.due.label',
defaultMessage: 'Due:',
},
customDueDateLabel: {
id: 'course-authoring.course-outline.xblock-status.custom-due-date.label',
defaultMessage: 'Custom due date: {relativeWeeksDue, plural, one {# week} other {# weeks}} from enrollment',
},
prerequisiteLabel: {
id: 'course-authoring.course-outline.xblock-status.prerequisite.label',
defaultMessage: 'Prerequisite: {prereqDisplayName}',
},
restrictedUnitAccess: {
id: 'course-authoring.course-outline.xblock-status.restrictedUnitAccess.text',
defaultMessage: 'Access to this unit is restricted to: {selectedGroupsLabel}',
},
restrictedUnitAccessToSomeContent: {
id: 'course-authoring.course-outline.xblock-status.restrictedUnitAccessToSomeContent.text',
defaultMessage: 'Access to some content in this unit is restricted to specific groups of learners',
},
gradingPolicyMismatchText: {
id: 'course-authoring.course-outline.xblock-status.gradingPolicyMismatch.text',
defaultMessage: 'This subsection is configured as "{gradingType}", which doesn\'t exist in the current grading policy.',
},
hiddenAfterEndDate: {
id: 'course-authoring.course-outline.xblock-status.hiddenAfterEndDate.text',
defaultMessage: 'Subsection is hidden after course end date',
},
hiddenAfterDueDate: {
id: 'course-authoring.course-outline.xblock-status.hiddenAfterDueDate.text',
defaultMessage: 'Subsection is hidden after due date',
},
});
export default messages;

View File

@@ -45,6 +45,7 @@ export const DivisionSchemes = /** @type {const} */ ({
});
export const VisibilityTypes = /** @type {const} */ ({
GATED: 'gated',
LIVE: 'live',
STAFF_ONLY: 'staff_only',
HIDE_AFTER_DUE: 'hide_after_due',