diff --git a/src/course-home/outline-tab/OutlineTab.jsx b/src/course-home/outline-tab/OutlineTab.jsx index 3fddf35a..5caeeefa 100644 --- a/src/course-home/outline-tab/OutlineTab.jsx +++ b/src/course-home/outline-tab/OutlineTab.jsx @@ -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 = () => { {rootCourseId && ( <> -
+
-
    - {courses[rootCourseId].sectionIds.map((sectionId) => ( -
    - ))} -
+ )}
diff --git a/src/course-home/outline-tab/OutlineTab.test.jsx b/src/course-home/outline-tab/OutlineTab.test.jsx index 5db8688a..458bc644 100644 --- a/src/course-home/outline-tab/OutlineTab.test.jsx +++ b/src/course-home/outline-tab/OutlineTab.test.jsx @@ -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' }); diff --git a/src/course-home/outline-tab/Section.jsx b/src/course-home/outline-tab/Section.jsx deleted file mode 100644 index 4e013c73..00000000 --- a/src/course-home/outline-tab/Section.jsx +++ /dev/null @@ -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 = ( -
-
- {complete ? ( -
-
- {title} - - , {intl.formatMessage(complete ? messages.completedSection : messages.incompleteSection)} - -
- {hideFromTOC && ( -
- {hideFromTOC && ( - - - - {intl.formatMessage(messages.hiddenSection)} - - - )} -
- )} -
- ); - - return ( -
  • - { setOpen(!open); }} - iconWhenClosed={( - { setOpen(true); }} - size="sm" - /> - )} - iconWhenOpen={( - { setOpen(false); }} - size="sm" - /> - )} - > -
      - {sequenceIds.map((sequenceId, index) => ( - - ))} -
    -
    -
  • - ); -}; - -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); diff --git a/src/course-home/outline-tab/SequenceLink.jsx b/src/course-home/outline-tab/SequenceLink.jsx deleted file mode 100644 index 41af780c..00000000 --- a/src/course-home/outline-tab/SequenceLink.jsx +++ /dev/null @@ -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 = {title}; - const displayTitle = showLink ? coursewareUrl : title; - - const dueDateMessage = ( - - ), - description: description || '', - }} - /> - ); - - const noDueDateMessage = ( - - ), - description: description || '', - }} - /> - ); - - return ( -
  • -
    -
    -
    - {complete ? ( - - ) : ( - - )} -
    -
    - {displayTitle} - - , {intl.formatMessage(complete ? messages.completedAssignment : messages.incompleteAssignment)} - - -
    -
    - {hideFromTOC && ( -
    - - - - {intl.formatMessage(messages.hiddenSequenceLink)} - - -
    - )} -
    - - {due ? dueDateMessage : noDueDateMessage} - -
    -
    -
  • - ); -}; - -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); diff --git a/src/course-home/outline-tab/messages.ts b/src/course-home/outline-tab/messages.ts index 46ac3bd2..4c660fee 100644 --- a/src/course-home/outline-tab/messages.ts +++ b/src/course-home/outline-tab/messages.ts @@ -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; diff --git a/src/course-home/outline-tab/section-outline/HiddenSequenceLink.tsx b/src/course-home/outline-tab/section-outline/HiddenSequenceLink.tsx new file mode 100644 index 00000000..b8253801 --- /dev/null +++ b/src/course-home/outline-tab/section-outline/HiddenSequenceLink.tsx @@ -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 = () => { + const intl = useIntl(); + + return ( +
    + + + + {intl.formatMessage(messages.hiddenSequenceLink)} + + +
    + ); +}; + +export default HiddenSequenceLink; diff --git a/src/course-home/outline-tab/section-outline/Section.tsx b/src/course-home/outline-tab/section-outline/Section.tsx new file mode 100644 index 00000000..f9052940 --- /dev/null +++ b/src/course-home/outline-tab/section-outline/Section.tsx @@ -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 = ({ + 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 ( +
  • + } + open={open} + onToggle={() => { setOpen(!open); }} + iconWhenClosed={( + { setOpen(true); }} + size="sm" + /> + )} + iconWhenOpen={( + { setOpen(false); }} + size="sm" + /> + )} + > +
      + {sequenceIds.map((sequenceId, index) => ( + + ))} +
    +
    +
  • + ); +}; + +export default Section; diff --git a/src/course-home/outline-tab/section-outline/SectionTitle.tsx b/src/course-home/outline-tab/section-outline/SectionTitle.tsx new file mode 100644 index 00000000..69c4ddfd --- /dev/null +++ b/src/course-home/outline-tab/section-outline/SectionTitle.tsx @@ -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 = ({ complete, hideFromTOC, title }) => { + const intl = useIntl(); + return ( +
    +
    + {complete ? ( +
    +
    + {title} + + , {intl.formatMessage(complete ? messages.completedSection : messages.incompleteSection)} + +
    + {hideFromTOC && ( +
    + {hideFromTOC && ( + + + + {intl.formatMessage(messages.hiddenSection)} + + + )} +
    + )} +
    + ); +}; + +export default SectionTitle; diff --git a/src/course-home/outline-tab/section-outline/SequenceDueDate.tsx b/src/course-home/outline-tab/section-outline/SequenceDueDate.tsx new file mode 100644 index 00000000..9c253792 --- /dev/null +++ b/src/course-home/outline-tab/section-outline/SequenceDueDate.tsx @@ -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 = ({ + 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: ( + + ), + description: description || '', + }, + ); + } + + return ( +
    + + {dueDateMessage} + +
    + ); +}; + +export default SequenceDueDate; diff --git a/src/course-home/outline-tab/section-outline/SequenceLink.tsx b/src/course-home/outline-tab/section-outline/SequenceLink.tsx new file mode 100644 index 00000000..a0eda629 --- /dev/null +++ b/src/course-home/outline-tab/section-outline/SequenceLink.tsx @@ -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 = ({ + id, + first, + sequence, +}) => { + const { + complete, + description, + due, + showLink, + title, + hideFromTOC, + } = sequence; + + return ( +
  • +
    + + {hideFromTOC && ( + + )} + +
    +
  • + ); +}; + +export default SequenceLink; diff --git a/src/course-home/outline-tab/section-outline/SequenceTitle.tsx b/src/course-home/outline-tab/section-outline/SequenceTitle.tsx new file mode 100644 index 00000000..ec035dfa --- /dev/null +++ b/src/course-home/outline-tab/section-outline/SequenceTitle.tsx @@ -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 = ({ + complete, + showLink, + title, + sequence, + id, +}) => { + const intl = useIntl(); + const courseId = useContextId(); + const coursewareUrl = {title}; + const displayTitle = showLink ? coursewareUrl : title; + + return ( +
    +
    + {complete ? ( + + ) : ( + + )} +
    +
    + {displayTitle} + + , {intl.formatMessage(complete ? messages.completedAssignment : messages.incompleteAssignment)} + + +
    +
    + ); +}; + +export default SequenceTitle; diff --git a/src/plugin-slots/CourseHomeSectionOutlineSlot/README.md b/src/plugin-slots/CourseHomeSectionOutlineSlot/README.md new file mode 100644 index 00000000..322d4bfd --- /dev/null +++ b/src/plugin-slots/CourseHomeSectionOutlineSlot/README.md @@ -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 +![Trigger slot with default content](./screenshot_default.png) + +### Added with custom component +![📌 in trigger slot](./screenshot_custom.png) + +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) => ( + <> +

    📌

    +
      + {props.sectionIds.map((sectionId) => ( +
      + ))} +
    + + ), + }, + }, + ] + } + }, +} + +export default config; +``` diff --git a/src/plugin-slots/CourseHomeSectionOutlineSlot/index.tsx b/src/plugin-slots/CourseHomeSectionOutlineSlot/index.tsx new file mode 100644 index 00000000..5d7ae6e0 --- /dev/null +++ b/src/plugin-slots/CourseHomeSectionOutlineSlot/index.tsx @@ -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 = ({ + expandAll, sections, sectionIds, +}) => ( + +
      + {sectionIds.map((sectionId) => ( +
      + ))} +
    +
    +); + +export default CourseHomeSectionOutlineSlot; diff --git a/src/plugin-slots/CourseHomeSectionOutlineSlot/screenshot_custom.png b/src/plugin-slots/CourseHomeSectionOutlineSlot/screenshot_custom.png new file mode 100644 index 00000000..a335f0bc Binary files /dev/null and b/src/plugin-slots/CourseHomeSectionOutlineSlot/screenshot_custom.png differ diff --git a/src/plugin-slots/CourseHomeSectionOutlineSlot/screenshot_default.png b/src/plugin-slots/CourseHomeSectionOutlineSlot/screenshot_default.png new file mode 100644 index 00000000..370b8518 Binary files /dev/null and b/src/plugin-slots/CourseHomeSectionOutlineSlot/screenshot_default.png differ