diff --git a/src/courseware/course/sequence/Sequence.jsx b/src/courseware/course/sequence/Sequence.jsx index 684b8b62..e78f99dd 100644 --- a/src/courseware/course/sequence/Sequence.jsx +++ b/src/courseware/course/sequence/Sequence.jsx @@ -20,6 +20,7 @@ import { useModel } from '../../../generic/model-store'; import CourseLicense from '../course-license'; import messages from './messages'; +import HiddenAfterDue from './hidden-after-due'; import { SequenceNavigation, UnitNavigation } from './sequence-navigation'; import SequenceContent from './SequenceContent'; import NotificationTray from '../NotificationTray'; @@ -144,6 +145,12 @@ function Sequence({ ); } + if (sequenceStatus === 'loaded' && sequence.isHiddenAfterDue) { + // Shouldn't even be here - these sequences are normally stripped out of the navigation. + // But we are here, so render a notice instead of the normal content. + return ; + } + /* TODO: When the micro-frontend supports viewing special exams without redirecting to the legacy experience, we can remove this whole conditional. For now, though, we show the spinner here diff --git a/src/courseware/course/sequence/Sequence.test.jsx b/src/courseware/course/sequence/Sequence.test.jsx index fc3272c6..76ac4233 100644 --- a/src/courseware/course/sequence/Sequence.test.jsx +++ b/src/courseware/course/sequence/Sequence.test.jsx @@ -81,6 +81,37 @@ describe('Sequence', () => { expect(screen.queryByText('Loading locked content messaging...')).not.toBeInTheDocument(); }); + it('renders correctly for hidden after due content', async () => { + const sequenceBlocks = [Factory.build( + 'block', + { type: 'sequential', children: [unitBlocks.map(block => block.id)] }, + { courseId: courseMetadata.id }, + )]; + const sequenceMetadata = [Factory.build( + 'sequenceMetadata', + { is_hidden_after_due: true }, + { courseId: courseMetadata.id, unitBlocks, sequenceBlock: sequenceBlocks[0] }, + )]; + const testStore = await initializeTestStore( + { + courseMetadata, unitBlocks, sequenceBlocks, sequenceMetadata, + }, false, + ); + render( + , + { store: testStore }, + ); + + await waitFor(() => { + expect(screen.queryByText('The due date for this assignment has passed.')).toBeInTheDocument(); + }); + expect(screen.getByRole('link', { name: 'progress page' })) + .toHaveAttribute('href', 'http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course_1/progress'); + + // No normal content or navigation should be rendered. Just the above alert. + expect(screen.queryAllByRole('button').length).toEqual(0); + }); + it('renders correctly for exam content', async () => { // Exams should NOT render in the Sequence. They should permanently show a spinner until the // application redirects away from the page. Note that this component is not responsible for diff --git a/src/courseware/course/sequence/hidden-after-due/HiddenAfterDue.jsx b/src/courseware/course/sequence/hidden-after-due/HiddenAfterDue.jsx new file mode 100644 index 00000000..6b770b37 --- /dev/null +++ b/src/courseware/course/sequence/hidden-after-due/HiddenAfterDue.jsx @@ -0,0 +1,52 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { injectIntl, intlShape, FormattedMessage } from '@edx/frontend-platform/i18n'; +import { Alert, Hyperlink } from '@edx/paragon'; +import { Info } from '@edx/paragon/icons'; + +import { useModel } from '../../../../generic/model-store'; + +import messages from './messages'; + +function HiddenAfterDue({ courseId, intl }) { + const { tabs } = useModel('coursewareMeta', courseId); + + const progressTab = tabs.find(tab => tab.slug === 'progress'); + const progressLink = progressTab && progressTab.url && ( + + {intl.formatMessage(messages.progressPage)} + + ); + + return ( + +

{intl.formatMessage(messages.header)}

+

+ {intl.formatMessage(messages.description)} + {progressLink && ( + <> +
+ + + )} +

+
+ ); +} + +HiddenAfterDue.propTypes = { + intl: intlShape.isRequired, + courseId: PropTypes.string.isRequired, +}; + +export default injectIntl(HiddenAfterDue); diff --git a/src/courseware/course/sequence/hidden-after-due/index.js b/src/courseware/course/sequence/hidden-after-due/index.js new file mode 100644 index 00000000..41df7093 --- /dev/null +++ b/src/courseware/course/sequence/hidden-after-due/index.js @@ -0,0 +1 @@ +export { default } from './HiddenAfterDue'; diff --git a/src/courseware/course/sequence/hidden-after-due/messages.js b/src/courseware/course/sequence/hidden-after-due/messages.js new file mode 100644 index 00000000..61706631 --- /dev/null +++ b/src/courseware/course/sequence/hidden-after-due/messages.js @@ -0,0 +1,23 @@ +import { defineMessages } from '@edx/frontend-platform/i18n'; + +const messages = defineMessages({ + header: { + id: 'learn.hiddenAfterDue.header', + defaultMessage: 'The due date for this assignment has passed.', + }, + description: { + id: 'learn.hiddenAfterDue.description', + defaultMessage: 'Because the due date has passed, this assignment is no longer available.', + }, + gradeAvailable: { + id: 'learn.hiddenAfterDue.gradeAvailable', + defaultMessage: 'If you have completed this assignment, your grade is available on the {progressPage}.', + }, + progressPage: { + id: 'learn.hiddenAfterDue.progressPage', + defaultMessage: 'progress page', + description: 'This is the text for the link embedded in learn.hiddenAfterDue.gradeAvailable', + }, +}); + +export default messages; diff --git a/src/courseware/data/__factories__/sequenceMetadata.factory.js b/src/courseware/data/__factories__/sequenceMetadata.factory.js index 3ec46b8e..17bca146 100644 --- a/src/courseware/data/__factories__/sequenceMetadata.factory.js +++ b/src/courseware/data/__factories__/sequenceMetadata.factory.js @@ -58,6 +58,7 @@ Factory.define('sequenceMetadata') save_position: true, prev_url: null, is_time_limited: false, + is_hidden_after_due: false, show_completion: true, banner_text: null, format: 'Homework', diff --git a/src/courseware/data/api.js b/src/courseware/data/api.js index 4e1b10d5..08d757d4 100644 --- a/src/courseware/data/api.js +++ b/src/courseware/data/api.js @@ -253,6 +253,7 @@ function normalizeSequenceMetadata(sequence) { gatedContent: camelCaseObject(sequence.gated_content), isTimeLimited: sequence.is_time_limited, isProctored: sequence.is_proctored, + isHiddenAfterDue: sequence.is_hidden_after_due, // Position comes back from the server 1-indexed. Adjust here. activeUnitIndex: sequence.position ? sequence.position - 1 : 0, saveUnitPosition: sequence.save_position, diff --git a/src/courseware/data/pact-tests/lmsPact.test.jsx b/src/courseware/data/pact-tests/lmsPact.test.jsx index 60f59503..2129c32b 100644 --- a/src/courseware/data/pact-tests/lmsPact.test.jsx +++ b/src/courseware/data/pact-tests/lmsPact.test.jsx @@ -331,6 +331,7 @@ describe('Courseware Service', () => { item_id: string('block-v1:edX+DemoX+Demo_Course+type@sequential+block@basic_questions'), is_time_limited: boolean(false), is_proctored: boolean(false), + is_hidden_after_due: boolean(false), position: null, tag: boolean('sequential'), banner_text: null, @@ -367,6 +368,7 @@ describe('Courseware Service', () => { }, isTimeLimited: false, isProctored: false, + isHiddenAfterDue: false, activeUnitIndex: 0, saveUnitPosition: false, showCompletion: false, diff --git a/src/pacts/frontend-app-learning-lms.json b/src/pacts/frontend-app-learning-lms.json index 100a02eb..2a6bc058 100644 --- a/src/pacts/frontend-app-learning-lms.json +++ b/src/pacts/frontend-app-learning-lms.json @@ -489,6 +489,7 @@ "item_id": "block-v1:edX+DemoX+Demo_Course+type@sequential+block@basic_questions", "is_time_limited": false, "is_proctored": false, + "is_hidden_after_due": true, "position": null, "tag": "sequential", "banner_text": null,