feat: add section outline plugin (#1632)
This PR adds a plugin slot for the section list in the outline tab. This plugin can be used to add custom content before the list or add extra content to the titles for sections and subsections. To accomplish this, some of the smaller components inside Section and SequenceLink have been extrapolated into their own components so that they can be easily imported for use in plugins.
This commit is contained in:
@@ -15,7 +15,6 @@ import WeeklyLearningGoalCard from './widgets/WeeklyLearningGoalCard';
|
||||
import CourseTools from './widgets/CourseTools';
|
||||
import { fetchOutlineTab } from '../data';
|
||||
import messages from './messages';
|
||||
import Section from './Section';
|
||||
import ShiftDatesAlert from '../suggested-schedule-messaging/ShiftDatesAlert';
|
||||
import UpgradeNotification from '../../generic/upgrade-notification/UpgradeNotification';
|
||||
import UpgradeToShiftDatesAlert from '../suggested-schedule-messaging/UpgradeToShiftDatesAlert';
|
||||
@@ -28,6 +27,7 @@ import { useModel } from '../../generic/model-store';
|
||||
import WelcomeMessage from './widgets/WelcomeMessage';
|
||||
import ProctoringInfoPanel from './widgets/ProctoringInfoPanel';
|
||||
import AccountActivationAlert from '../../alerts/logistration-alert/AccountActivationAlert';
|
||||
import CourseHomeSectionOutlineSlot from '../../plugin-slots/CourseHomeSectionOutlineSlot';
|
||||
|
||||
const OutlineTab = () => {
|
||||
const intl = useIntl();
|
||||
@@ -165,24 +165,18 @@ const OutlineTab = () => {
|
||||
<WelcomeMessage courseId={courseId} nextElementRef={expandButtonRef} />
|
||||
{rootCourseId && (
|
||||
<>
|
||||
<div className="row w-100 m-0 mb-3 justify-content-end">
|
||||
<div id="expand-button-row" className="row w-100 m-0 mb-3 justify-content-end">
|
||||
<div className="col-12 col-md-auto p-0">
|
||||
<Button ref={expandButtonRef} variant="outline-primary" block onClick={() => { setExpandAll(!expandAll); }}>
|
||||
{expandAll ? intl.formatMessage(messages.collapseAll) : intl.formatMessage(messages.expandAll)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<ol id="courseHome-outline" className="list-unstyled">
|
||||
{courses[rootCourseId].sectionIds.map((sectionId) => (
|
||||
<Section
|
||||
key={sectionId}
|
||||
courseId={courseId}
|
||||
defaultOpen={sections[sectionId].resumeBlock}
|
||||
expand={expandAll}
|
||||
section={sections[sectionId]}
|
||||
/>
|
||||
))}
|
||||
</ol>
|
||||
<CourseHomeSectionOutlineSlot
|
||||
expandAll={expandAll}
|
||||
sectionIds={courses[rootCourseId].sectionIds}
|
||||
sections={sections}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -168,7 +168,7 @@ describe('Outline Tab', () => {
|
||||
course_blocks: { blocks: courseBlocks.blocks },
|
||||
});
|
||||
await fetchAndRender();
|
||||
expect(screen.getByTitle('Completed section')).toBeInTheDocument();
|
||||
expect(screen.getByLabelText('Completed section')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays correct icon for incomplete assignment', async () => {
|
||||
@@ -177,7 +177,7 @@ describe('Outline Tab', () => {
|
||||
course_blocks: { blocks: courseBlocks.blocks },
|
||||
});
|
||||
await fetchAndRender();
|
||||
expect(screen.getByTitle('Incomplete section')).toBeInTheDocument();
|
||||
expect(screen.getByLabelText('Incomplete section')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('SequenceLink displays link', async () => {
|
||||
@@ -293,7 +293,7 @@ describe('Outline Tab', () => {
|
||||
expect(showMoreButton).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fit('dismisses message', async () => {
|
||||
it('dismisses message', async () => {
|
||||
expect(screen.getByTestId('alert-container-welcome')).toBeInTheDocument();
|
||||
const dismissButton = screen.queryByRole('button', { name: 'Dismiss' });
|
||||
const expandButton = screen.queryByRole('button', { name: 'Expand all' });
|
||||
|
||||
@@ -1,137 +0,0 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import { Collapsible, IconButton, Icon } from '@openedx/paragon';
|
||||
import { faCheckCircle as fasCheckCircle, faMinus, faPlus } from '@fortawesome/free-solid-svg-icons';
|
||||
import { faCheckCircle as farCheckCircle } from '@fortawesome/free-regular-svg-icons';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
|
||||
import { DisabledVisible } from '@openedx/paragon/icons';
|
||||
import SequenceLink from './SequenceLink';
|
||||
import { useModel } from '../../generic/model-store';
|
||||
|
||||
import genericMessages from '../../generic/messages';
|
||||
import messages from './messages';
|
||||
|
||||
const Section = ({
|
||||
courseId,
|
||||
defaultOpen,
|
||||
expand,
|
||||
intl,
|
||||
section,
|
||||
}) => {
|
||||
const {
|
||||
complete,
|
||||
sequenceIds,
|
||||
title,
|
||||
hideFromTOC,
|
||||
} = section;
|
||||
const {
|
||||
courseBlocks: {
|
||||
sequences,
|
||||
},
|
||||
} = useModel('outline', courseId);
|
||||
|
||||
const [open, setOpen] = useState(defaultOpen);
|
||||
|
||||
useEffect(() => {
|
||||
setOpen(expand);
|
||||
}, [expand]);
|
||||
|
||||
useEffect(() => {
|
||||
setOpen(defaultOpen);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const sectionTitle = (
|
||||
<div className="d-flex row w-100 m-0">
|
||||
<div className="col-auto p-0">
|
||||
{complete ? (
|
||||
<FontAwesomeIcon
|
||||
icon={fasCheckCircle}
|
||||
fixedWidth
|
||||
className="float-left mt-1 text-success"
|
||||
aria-hidden="true"
|
||||
title={intl.formatMessage(messages.completedSection)}
|
||||
/>
|
||||
) : (
|
||||
<FontAwesomeIcon
|
||||
icon={farCheckCircle}
|
||||
fixedWidth
|
||||
className="float-left mt-1 text-gray-400"
|
||||
aria-hidden="true"
|
||||
title={intl.formatMessage(messages.incompleteSection)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="col-7 ml-3 p-0 font-weight-bold text-dark-500">
|
||||
<span className="align-middle col-6">{title}</span>
|
||||
<span className="sr-only">
|
||||
, {intl.formatMessage(complete ? messages.completedSection : messages.incompleteSection)}
|
||||
</span>
|
||||
</div>
|
||||
{hideFromTOC && (
|
||||
<div className="row">
|
||||
{hideFromTOC && (
|
||||
<span className="small d-flex align-content-end">
|
||||
<Icon className="mr-2" src={DisabledVisible} data-testid="hide-from-toc-section-icon" />
|
||||
<span data-testid="hide-from-toc-section-text">
|
||||
{intl.formatMessage(messages.hiddenSection)}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<li>
|
||||
<Collapsible
|
||||
className="mb-2"
|
||||
styling="card-lg"
|
||||
title={sectionTitle}
|
||||
open={open}
|
||||
onToggle={() => { setOpen(!open); }}
|
||||
iconWhenClosed={(
|
||||
<IconButton
|
||||
alt={intl.formatMessage(messages.openSection)}
|
||||
icon={faPlus}
|
||||
onClick={() => { setOpen(true); }}
|
||||
size="sm"
|
||||
/>
|
||||
)}
|
||||
iconWhenOpen={(
|
||||
<IconButton
|
||||
alt={intl.formatMessage(genericMessages.close)}
|
||||
icon={faMinus}
|
||||
onClick={() => { setOpen(false); }}
|
||||
size="sm"
|
||||
/>
|
||||
)}
|
||||
>
|
||||
<ol className="list-unstyled">
|
||||
{sequenceIds.map((sequenceId, index) => (
|
||||
<SequenceLink
|
||||
key={sequenceId}
|
||||
id={sequenceId}
|
||||
courseId={courseId}
|
||||
sequence={sequences[sequenceId]}
|
||||
first={index === 0}
|
||||
/>
|
||||
))}
|
||||
</ol>
|
||||
</Collapsible>
|
||||
</li>
|
||||
);
|
||||
};
|
||||
|
||||
Section.propTypes = {
|
||||
courseId: PropTypes.string.isRequired,
|
||||
defaultOpen: PropTypes.bool.isRequired,
|
||||
expand: PropTypes.bool.isRequired,
|
||||
intl: intlShape.isRequired,
|
||||
section: PropTypes.shape().isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(Section);
|
||||
@@ -1,147 +0,0 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import classNames from 'classnames';
|
||||
import { Link } from 'react-router-dom';
|
||||
import {
|
||||
FormattedMessage,
|
||||
FormattedTime,
|
||||
injectIntl,
|
||||
intlShape,
|
||||
} from '@edx/frontend-platform/i18n';
|
||||
import { faCheckCircle as fasCheckCircle } from '@fortawesome/free-solid-svg-icons';
|
||||
import { faCheckCircle as farCheckCircle } from '@fortawesome/free-regular-svg-icons';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
|
||||
import { Icon } from '@openedx/paragon';
|
||||
import { Block } from '@openedx/paragon/icons';
|
||||
import EffortEstimate from '../../shared/effort-estimate';
|
||||
import { useModel } from '../../generic/model-store';
|
||||
import messages from './messages';
|
||||
|
||||
const SequenceLink = ({
|
||||
id,
|
||||
intl,
|
||||
courseId,
|
||||
first,
|
||||
sequence,
|
||||
}) => {
|
||||
const {
|
||||
complete,
|
||||
description,
|
||||
due,
|
||||
showLink,
|
||||
title,
|
||||
hideFromTOC,
|
||||
} = sequence;
|
||||
const {
|
||||
userTimezone,
|
||||
} = useModel('outline', courseId);
|
||||
|
||||
const timezoneFormatArgs = userTimezone ? { timeZone: userTimezone } : {};
|
||||
|
||||
const coursewareUrl = <Link to={`/course/${courseId}/${id}`}>{title}</Link>;
|
||||
const displayTitle = showLink ? coursewareUrl : title;
|
||||
|
||||
const dueDateMessage = (
|
||||
<FormattedMessage
|
||||
id="learning.outline.sequence-due-date-set"
|
||||
defaultMessage="{description} due {assignmentDue}"
|
||||
description="Used below an assignment title"
|
||||
values={{
|
||||
assignmentDue: (
|
||||
<FormattedTime
|
||||
key={`${id}-due`}
|
||||
day="numeric"
|
||||
month="short"
|
||||
year="numeric"
|
||||
timeZoneName="short"
|
||||
value={due}
|
||||
{...timezoneFormatArgs}
|
||||
/>
|
||||
),
|
||||
description: description || '',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
const noDueDateMessage = (
|
||||
<FormattedMessage
|
||||
id="learning.outline.sequence-due-date-not-set"
|
||||
defaultMessage="{description}"
|
||||
description="Used below an assignment title"
|
||||
values={{
|
||||
assignmentDue: (
|
||||
<FormattedTime
|
||||
key={`${id}-due`}
|
||||
day="numeric"
|
||||
month="short"
|
||||
year="numeric"
|
||||
timeZoneName="short"
|
||||
value={due}
|
||||
{...timezoneFormatArgs}
|
||||
/>
|
||||
),
|
||||
description: description || '',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<li>
|
||||
<div className={classNames('', { 'mt-2 pt-2 border-top border-light': !first })}>
|
||||
<div className="row w-100 m-0">
|
||||
<div className="col-auto p-0">
|
||||
{complete ? (
|
||||
<FontAwesomeIcon
|
||||
icon={fasCheckCircle}
|
||||
fixedWidth
|
||||
className="float-left text-success mt-1"
|
||||
aria-hidden={complete}
|
||||
title={intl.formatMessage(messages.completedAssignment)}
|
||||
/>
|
||||
) : (
|
||||
<FontAwesomeIcon
|
||||
icon={farCheckCircle}
|
||||
fixedWidth
|
||||
className="float-left text-gray-400 mt-1"
|
||||
aria-hidden={complete}
|
||||
title={intl.formatMessage(messages.incompleteAssignment)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="col-10 p-0 ml-3 text-break">
|
||||
<span className="align-middle">{displayTitle}</span>
|
||||
<span className="sr-only">
|
||||
, {intl.formatMessage(complete ? messages.completedAssignment : messages.incompleteAssignment)}
|
||||
</span>
|
||||
<EffortEstimate className="ml-3 align-middle" block={sequence} />
|
||||
</div>
|
||||
</div>
|
||||
{hideFromTOC && (
|
||||
<div className="row w-100 my-2 mx-4 pl-3">
|
||||
<span className="small d-flex">
|
||||
<Icon className="mr-2" src={Block} data-testid="hide-from-toc-sequence-link-icon" />
|
||||
<span data-testid="hide-from-toc-sequence-link-text">
|
||||
{intl.formatMessage(messages.hiddenSequenceLink)}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="row w-100 m-0 ml-3 pl-3">
|
||||
<small className="text-body pl-2">
|
||||
{due ? dueDateMessage : noDueDateMessage}
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
};
|
||||
|
||||
SequenceLink.propTypes = {
|
||||
id: PropTypes.string.isRequired,
|
||||
intl: intlShape.isRequired,
|
||||
courseId: PropTypes.string.isRequired,
|
||||
first: PropTypes.bool.isRequired,
|
||||
sequence: PropTypes.shape().isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(SequenceLink);
|
||||
@@ -341,6 +341,16 @@ const messages = defineMessages({
|
||||
defaultMessage: 'Onboarding Past Due',
|
||||
description: 'Text that show when the deadline of proctortrack onboarding exam has passed, it appears on button that start the onboarding exam however for this case the button is disabled for obvious reason',
|
||||
},
|
||||
sequenceDueDate: {
|
||||
id: 'learning.outline.sequence-due-date-set',
|
||||
defaultMessage: '{description} due {assignmentDue}',
|
||||
description: 'Used below an assignment title',
|
||||
},
|
||||
sequenceNoDueDate: {
|
||||
id: 'learning.outline.sequence-due-date-not-set',
|
||||
defaultMessage: '{description}',
|
||||
description: 'Used below an assignment title',
|
||||
},
|
||||
});
|
||||
|
||||
export default messages;
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import React from 'react';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { Icon } from '@openedx/paragon';
|
||||
import { Block } from '@openedx/paragon/icons';
|
||||
|
||||
import messages from '../messages';
|
||||
|
||||
interface Props {}
|
||||
|
||||
const HiddenSequenceLink: React.FC<Props> = () => {
|
||||
const intl = useIntl();
|
||||
|
||||
return (
|
||||
<div className="row w-100 my-2 mx-4 pl-3">
|
||||
<span className="small d-flex">
|
||||
<Icon className="mr-2" src={Block} data-testid="hide-from-toc-sequence-link-icon" />
|
||||
<span data-testid="hide-from-toc-sequence-link-text">
|
||||
{intl.formatMessage(messages.hiddenSequenceLink)}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default HiddenSequenceLink;
|
||||
94
src/course-home/outline-tab/section-outline/Section.tsx
Normal file
94
src/course-home/outline-tab/section-outline/Section.tsx
Normal file
@@ -0,0 +1,94 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { Collapsible, IconButton } from '@openedx/paragon';
|
||||
import { Minus, Plus } from '@openedx/paragon/icons';
|
||||
|
||||
import { useModel } from '../../../generic/model-store';
|
||||
import genericMessages from '../../../generic/messages';
|
||||
import { useContextId } from '../../../data/hooks';
|
||||
import messages from '../messages';
|
||||
import SectionTitle from './SectionTitle';
|
||||
import SequenceLink from './SequenceLink';
|
||||
|
||||
interface Props {
|
||||
defaultOpen: boolean;
|
||||
expand: boolean;
|
||||
section: {
|
||||
complete: boolean;
|
||||
sequenceIds: string[];
|
||||
title: string;
|
||||
hideFromTOC: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
const Section: React.FC<Props> = ({
|
||||
defaultOpen,
|
||||
expand,
|
||||
section,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
const courseId = useContextId();
|
||||
const {
|
||||
complete,
|
||||
sequenceIds,
|
||||
title,
|
||||
hideFromTOC,
|
||||
} = section;
|
||||
const {
|
||||
courseBlocks: {
|
||||
sequences,
|
||||
},
|
||||
} = useModel('outline', courseId);
|
||||
|
||||
const [open, setOpen] = useState(defaultOpen);
|
||||
|
||||
useEffect(() => {
|
||||
setOpen(expand);
|
||||
}, [expand]);
|
||||
|
||||
useEffect(() => {
|
||||
setOpen(defaultOpen);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<li>
|
||||
<Collapsible
|
||||
className="mb-2"
|
||||
styling="card-lg"
|
||||
title={<SectionTitle {...{ complete, hideFromTOC, title }} />}
|
||||
open={open}
|
||||
onToggle={() => { setOpen(!open); }}
|
||||
iconWhenClosed={(
|
||||
<IconButton
|
||||
alt={intl.formatMessage(messages.openSection)}
|
||||
iconAs={Plus}
|
||||
onClick={() => { setOpen(true); }}
|
||||
size="sm"
|
||||
/>
|
||||
)}
|
||||
iconWhenOpen={(
|
||||
<IconButton
|
||||
alt={intl.formatMessage(genericMessages.close)}
|
||||
iconAs={Minus}
|
||||
onClick={() => { setOpen(false); }}
|
||||
size="sm"
|
||||
/>
|
||||
)}
|
||||
>
|
||||
<ol className="list-unstyled">
|
||||
{sequenceIds.map((sequenceId, index) => (
|
||||
<SequenceLink
|
||||
key={sequenceId}
|
||||
id={sequenceId}
|
||||
sequence={sequences[sequenceId]}
|
||||
first={index === 0}
|
||||
/>
|
||||
))}
|
||||
</ol>
|
||||
</Collapsible>
|
||||
</li>
|
||||
);
|
||||
};
|
||||
|
||||
export default Section;
|
||||
59
src/course-home/outline-tab/section-outline/SectionTitle.tsx
Normal file
59
src/course-home/outline-tab/section-outline/SectionTitle.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import React from 'react';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { Icon } from '@openedx/paragon';
|
||||
import { CheckCircle, CheckCircleOutline, DisabledVisible } from '@openedx/paragon/icons';
|
||||
|
||||
import messages from '../messages';
|
||||
|
||||
interface Props {
|
||||
complete: boolean;
|
||||
hideFromTOC: boolean;
|
||||
title: string;
|
||||
}
|
||||
|
||||
const SectionTitle: React.FC<Props> = ({ complete, hideFromTOC, title }) => {
|
||||
const intl = useIntl();
|
||||
return (
|
||||
<div className="d-flex row w-100 m-0">
|
||||
<div className="col-auto p-0">
|
||||
{complete ? (
|
||||
<Icon
|
||||
src={CheckCircle}
|
||||
className="float-left mt-1 text-success"
|
||||
aria-hidden="true"
|
||||
svgAttrs={{ 'aria-label': intl.formatMessage(messages.completedSection) }}
|
||||
size="sm"
|
||||
/>
|
||||
) : (
|
||||
<Icon
|
||||
src={CheckCircleOutline}
|
||||
className="float-left mt-1 text-gray-400"
|
||||
aria-hidden="true"
|
||||
svgAttrs={{ 'aria-label': intl.formatMessage(messages.incompleteSection) }}
|
||||
size="sm"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="col-7 ml-3 p-0 font-weight-bold text-dark-500">
|
||||
<span className="align-middle col-6">{title}</span>
|
||||
<span className="sr-only">
|
||||
, {intl.formatMessage(complete ? messages.completedSection : messages.incompleteSection)}
|
||||
</span>
|
||||
</div>
|
||||
{hideFromTOC && (
|
||||
<div className="row">
|
||||
{hideFromTOC && (
|
||||
<span className="small d-flex align-content-end">
|
||||
<Icon className="mr-2" src={DisabledVisible} data-testid="hide-from-toc-section-icon" />
|
||||
<span data-testid="hide-from-toc-section-text">
|
||||
{intl.formatMessage(messages.hiddenSection)}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SectionTitle;
|
||||
@@ -0,0 +1,60 @@
|
||||
import React from 'react';
|
||||
import { FormattedTime, useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { useModel } from '../../../generic/model-store';
|
||||
|
||||
import { useContextId } from '../../../data/hooks';
|
||||
import messages from '../messages';
|
||||
|
||||
interface Props {
|
||||
due: string;
|
||||
id: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
const SequenceDueDate: React.FC<Props> = ({
|
||||
due,
|
||||
id,
|
||||
description,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
const courseId = useContextId();
|
||||
let dueDateMessage: string | React.ReactNode = intl.formatMessage(
|
||||
messages.sequenceNoDueDate,
|
||||
{ description: description || '' },
|
||||
);
|
||||
const {
|
||||
userTimezone,
|
||||
} = useModel('outline', courseId);
|
||||
|
||||
if (due) {
|
||||
const timezoneFormatArgs = userTimezone ? { timeZone: userTimezone } : {};
|
||||
|
||||
dueDateMessage = intl.formatMessage(
|
||||
messages.sequenceDueDate,
|
||||
{
|
||||
assignmentDue: (
|
||||
<FormattedTime
|
||||
key={`${id}-due`}
|
||||
day="numeric"
|
||||
month="short"
|
||||
year="numeric"
|
||||
timeZoneName="short"
|
||||
value={due}
|
||||
{...timezoneFormatArgs}
|
||||
/>
|
||||
),
|
||||
description: description || '',
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="row w-100 m-0 ml-3 pl-3">
|
||||
<small className="text-body pl-2">
|
||||
{dueDateMessage}
|
||||
</small>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SequenceDueDate;
|
||||
56
src/course-home/outline-tab/section-outline/SequenceLink.tsx
Normal file
56
src/course-home/outline-tab/section-outline/SequenceLink.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import React from 'react';
|
||||
import classNames from 'classnames';
|
||||
|
||||
import SequenceDueDate from './SequenceDueDate';
|
||||
import HiddenSequenceLink from './HiddenSequenceLink';
|
||||
import SequenceTitle from './SequenceTitle';
|
||||
|
||||
interface Props {
|
||||
id: string;
|
||||
first: boolean;
|
||||
sequence: {
|
||||
complete: boolean;
|
||||
description: string;
|
||||
due: string;
|
||||
showLink: boolean;
|
||||
title: string;
|
||||
hideFromTOC: boolean;
|
||||
}
|
||||
}
|
||||
|
||||
const SequenceLink: React.FC<Props> = ({
|
||||
id,
|
||||
first,
|
||||
sequence,
|
||||
}) => {
|
||||
const {
|
||||
complete,
|
||||
description,
|
||||
due,
|
||||
showLink,
|
||||
title,
|
||||
hideFromTOC,
|
||||
} = sequence;
|
||||
|
||||
return (
|
||||
<li>
|
||||
<div className={classNames('', { 'mt-2 pt-2 border-top border-light': !first })}>
|
||||
<SequenceTitle
|
||||
{...{
|
||||
complete,
|
||||
showLink,
|
||||
title,
|
||||
sequence,
|
||||
id,
|
||||
}}
|
||||
/>
|
||||
{hideFromTOC && (
|
||||
<HiddenSequenceLink />
|
||||
)}
|
||||
<SequenceDueDate {...{ due, id, description }} />
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
};
|
||||
|
||||
export default SequenceLink;
|
||||
@@ -0,0 +1,63 @@
|
||||
import React from 'react';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Icon } from '@openedx/paragon';
|
||||
import { CheckCircleOutline, CheckCircle } from '@openedx/paragon/icons';
|
||||
|
||||
import EffortEstimate from '../../../shared/effort-estimate';
|
||||
import messages from '../messages';
|
||||
import { useContextId } from '../../../data/hooks';
|
||||
|
||||
interface Props {
|
||||
complete: boolean;
|
||||
showLink: boolean;
|
||||
title: string;
|
||||
sequence: object;
|
||||
id: string;
|
||||
}
|
||||
|
||||
const SequenceTitle: React.FC<Props> = ({
|
||||
complete,
|
||||
showLink,
|
||||
title,
|
||||
sequence,
|
||||
id,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
const courseId = useContextId();
|
||||
const coursewareUrl = <Link to={`/course/${courseId}/${id}`}>{title}</Link>;
|
||||
const displayTitle = showLink ? coursewareUrl : title;
|
||||
|
||||
return (
|
||||
<div className="row w-100 m-0">
|
||||
<div className="col-auto p-0">
|
||||
{complete ? (
|
||||
<Icon
|
||||
src={CheckCircle}
|
||||
className="float-left text-success mt-1"
|
||||
aria-hidden={complete}
|
||||
svgAttrs={{ 'aria-label': intl.formatMessage(messages.completedAssignment) }}
|
||||
size="sm"
|
||||
/>
|
||||
) : (
|
||||
<Icon
|
||||
src={CheckCircleOutline}
|
||||
className="float-left text-gray-400 mt-1"
|
||||
aria-hidden={complete}
|
||||
svgAttrs={{ 'aria-label': intl.formatMessage(messages.incompleteAssignment) }}
|
||||
size="sm"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="col-10 p-0 ml-3 text-break">
|
||||
<span className="align-middle">{displayTitle}</span>
|
||||
<span className="sr-only">
|
||||
, {intl.formatMessage(complete ? messages.completedAssignment : messages.incompleteAssignment)}
|
||||
</span>
|
||||
<EffortEstimate className="ml-3 align-middle" block={sequence} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SequenceTitle;
|
||||
56
src/plugin-slots/CourseHomeSectionOutlineSlot/README.md
Normal file
56
src/plugin-slots/CourseHomeSectionOutlineSlot/README.md
Normal file
@@ -0,0 +1,56 @@
|
||||
# Course Home Section Outline Slot
|
||||
|
||||
### Slot ID: `course_home_section_outline_slot`
|
||||
|
||||
## Description
|
||||
|
||||
This slot is used to replace/modify/hide the course home section outline.
|
||||
|
||||
## Example
|
||||
|
||||
### Default content
|
||||

|
||||
|
||||
### Added with custom component
|
||||

|
||||
|
||||
The following `env.config.jsx` will replace the course home section outline entirely.
|
||||
|
||||
```js
|
||||
import { DIRECT_PLUGIN, PLUGIN_OPERATIONS } from '@openedx/frontend-plugin-framework';
|
||||
import Section from '@src/course-home/outline-tab/section-outline/Section';
|
||||
|
||||
const config = {
|
||||
pluginSlots: {
|
||||
course_home_section_outline_slot: {
|
||||
keepDefault: false,
|
||||
plugins: [
|
||||
{
|
||||
op: PLUGIN_OPERATIONS.Insert,
|
||||
widget: {
|
||||
id: 'custom_section_outline_component',
|
||||
type: DIRECT_PLUGIN,
|
||||
RenderWidget: (props) => (
|
||||
<>
|
||||
<h1 className="d-xl-none">📌</h1>
|
||||
<ol id="courseHome-outline" className="list-unstyled">
|
||||
{props.sectionIds.map((sectionId) => (
|
||||
<Section
|
||||
key={props.sectionId}
|
||||
defaultOpen={props.sections[sectionId].resumeBlock}
|
||||
expand={props.expandAll}
|
||||
section={props.sections[sectionId]}
|
||||
/>
|
||||
))}
|
||||
</ol>
|
||||
</>
|
||||
),
|
||||
},
|
||||
},
|
||||
]
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
export default config;
|
||||
```
|
||||
31
src/plugin-slots/CourseHomeSectionOutlineSlot/index.tsx
Normal file
31
src/plugin-slots/CourseHomeSectionOutlineSlot/index.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import React from 'react';
|
||||
import { PluginSlot } from '@openedx/frontend-plugin-framework';
|
||||
import Section from '@src/course-home/outline-tab/section-outline/Section';
|
||||
|
||||
interface Props {
|
||||
expandAll: boolean;
|
||||
sections: object;
|
||||
sectionIds: string[];
|
||||
}
|
||||
|
||||
const CourseHomeSectionOutlineSlot: React.FC<Props> = ({
|
||||
expandAll, sections, sectionIds,
|
||||
}) => (
|
||||
<PluginSlot
|
||||
id="course_home_section_outline_slot"
|
||||
pluginProps={{ expandAll, sectionIds, sections }}
|
||||
>
|
||||
<ol id="courseHome-outline" className="list-unstyled">
|
||||
{sectionIds.map((sectionId) => (
|
||||
<Section
|
||||
key={sectionId}
|
||||
defaultOpen={sections[sectionId].resumeBlock}
|
||||
expand={expandAll}
|
||||
section={sections[sectionId]}
|
||||
/>
|
||||
))}
|
||||
</ol>
|
||||
</PluginSlot>
|
||||
);
|
||||
|
||||
export default CourseHomeSectionOutlineSlot;
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 130 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 100 KiB |
Reference in New Issue
Block a user