diff --git a/.env b/.env index 697910497c..917bdea634 100644 --- a/.env +++ b/.env @@ -48,3 +48,4 @@ TWITTER_HASHTAG='' TWITTER_URL='' USER_INFO_COOKIE_NAME='' OPTIMIZELY_FULL_STACK_SDK_KEY='' +SHOW_UNGRADED_ASSIGNMENT_PROGRESS='' diff --git a/.env.development b/.env.development index a4bceccdbf..56541aa1d4 100644 --- a/.env.development +++ b/.env.development @@ -50,3 +50,4 @@ SESSION_COOKIE_DOMAIN='localhost' CHAT_RESPONSE_URL='http://localhost:18000/api/learning_assistant/v1/course_id' PRIVACY_POLICY_URL='http://localhost:18000/privacy' OPTIMIZELY_FULL_STACK_SDK_KEY='' +SHOW_UNGRADED_ASSIGNMENT_PROGRESS='' diff --git a/.env.test b/.env.test index cb4d0e0fa2..faa4b4ffbe 100644 --- a/.env.test +++ b/.env.test @@ -47,3 +47,4 @@ TWITTER_HASHTAG='myedxjourney' TWITTER_URL='https://twitter.com/edXOnline' USER_INFO_COOKIE_NAME='edx-user-info' PRIVACY_POLICY_URL='http://localhost:18000/privacy' +SHOW_UNGRADED_ASSIGNMENT_PROGRESS='' diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 5feb86ad88..8634743f6d 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -9,21 +9,16 @@ on: jobs: tests: runs-on: ubuntu-latest - strategy: - matrix: - node: [18, 20] steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: - node-version: ${{ matrix.node }} + node-version-file: '.nvmrc' - run: make validate.ci - name: Archive code coverage results uses: actions/upload-artifact@v4 with: - name: code-coverage-report-${{ matrix.node }} - # When we're only using Node 20, replace the line above with the following: - # name: code-coverage-report + name: code-coverage-report path: coverage/*.* coverage: runs-on: ubuntu-latest @@ -33,9 +28,7 @@ jobs: - name: Download code coverage results uses: actions/download-artifact@v4 with: - name: code-coverage-report-20 - # When we're only using Node 20, replace the line above with the following: - # name: code-coverage-report + name: code-coverage-report - name: Upload coverage uses: codecov/codecov-action@v4 with: diff --git a/src/course-home/progress-tab/ProgressTab.test.jsx b/src/course-home/progress-tab/ProgressTab.test.jsx index a69426c3bd..1a9b15408d 100644 --- a/src/course-home/progress-tab/ProgressTab.test.jsx +++ b/src/course-home/progress-tab/ProgressTab.test.jsx @@ -1,6 +1,6 @@ import React from 'react'; import { Factory } from 'rosie'; -import { getConfig } from '@edx/frontend-platform'; +import { getConfig, setConfig } from '@edx/frontend-platform'; import { sendTrackEvent } from '@edx/frontend-platform/analytics'; import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; import { breakpoints } from '@openedx/paragon'; @@ -545,6 +545,111 @@ describe('Progress Tab', () => { await fetchAndRender(); expect(screen.getByText('Grades & Credit')).toBeInTheDocument(); }); + + it('does not render ungraded subsections when SHOW_UNGRADED_ASSIGNMENT_PROGRESS is false', async () => { + // The second assignment has has_graded_assignment set to false, so it should not be shown. + setTabData({ + section_scores: [ + { + display_name: 'First section', + subsections: [ + { + assignment_type: 'Homework', + block_key: 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@12345', + display_name: 'First subsection', + learner_has_access: true, + has_graded_assignment: true, + num_points_earned: 1, + num_points_possible: 2, + percent_graded: 1.0, + show_correctness: 'always', + show_grades: true, + url: 'http://learning.edx.org/course/course-v1:edX+Test+run/first_subsection', + }, + ], + }, + { + display_name: 'Second section', + subsections: [ + { + assignment_type: 'Homework', + display_name: 'Second subsection', + learner_has_access: true, + has_graded_assignment: false, + num_points_earned: 1, + num_points_possible: 1, + percent_graded: 1.0, + show_correctness: 'always', + show_grades: true, + url: 'http://learning.edx.org/course/course-v1:edX+Test+run/second_subsection', + }, + ], + }, + ], + }); + + await fetchAndRender(); + expect(screen.getByText('First subsection')).toBeInTheDocument(); + expect(screen.queryByText('Second subsection')).not.toBeInTheDocument(); + }); + + it('renders both graded and ungraded subsections when SHOW_UNGRADED_ASSIGNMENT_PROGRESS is true', async () => { + // The second assignment has has_graded_assignment set to false. + setConfig({ + ...getConfig(), + SHOW_UNGRADED_ASSIGNMENT_PROGRESS: true, + }); + + setTabData({ + section_scores: [ + { + display_name: 'First section', + subsections: [ + { + assignment_type: 'Homework', + block_key: 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@12345', + display_name: 'First subsection', + learner_has_access: true, + has_graded_assignment: true, + num_points_earned: 1, + num_points_possible: 2, + percent_graded: 1.0, + show_correctness: 'always', + show_grades: true, + url: 'http://learning.edx.org/course/course-v1:edX+Test+run/first_subsection', + }, + ], + }, + { + display_name: 'Second section', + subsections: [ + { + assignment_type: 'Homework', + display_name: 'Second subsection', + learner_has_access: true, + has_graded_assignment: false, + num_points_earned: 1, + num_points_possible: 1, + percent_graded: 1.0, + show_correctness: 'always', + show_grades: true, + url: 'http://learning.edx.org/course/course-v1:edX+Test+run/second_subsection', + }, + ], + }, + ], + }); + + await fetchAndRender(); + expect(screen.getByText('First subsection')).toBeInTheDocument(); + expect(screen.getByText('Second subsection')).toBeInTheDocument(); + + // reset config for other tests + setConfig({ + ...getConfig(), + SHOW_UNGRADED_ASSIGNMENT_PROGRESS: false, + }); + }); }); describe('Grade Summary', () => { @@ -809,7 +914,7 @@ describe('Progress Tab', () => { // Open the problem score drawer fireEvent.click(problemScoreDrawerToggle); - expect(screen.getByText('Problem Scores:')).toBeInTheDocument(); + expect(screen.getAllByText('Graded Scores:').length).toBeGreaterThan(1); expect(screen.getAllByText('0/1')).toHaveLength(3); }); @@ -821,6 +926,14 @@ describe('Progress Tab', () => { expect(screen.getByText('Detailed grades')).toBeInTheDocument(); expect(screen.getByText('You currently have no graded problem scores.')).toBeInTheDocument(); }); + + it('renders Detailed Grades table when section scores are populated', async () => { + await fetchAndRender(); + expect(screen.getByText('Detailed grades')).toBeInTheDocument(); + + expect(screen.getByText('First subsection')); + expect(screen.getByText('Second subsection')); + }); }); describe('Certificate Status', () => { diff --git a/src/course-home/progress-tab/grades/detailed-grades/DetailedGrades.jsx b/src/course-home/progress-tab/grades/detailed-grades/DetailedGrades.jsx index 40fb569c51..529859c52b 100644 --- a/src/course-home/progress-tab/grades/detailed-grades/DetailedGrades.jsx +++ b/src/course-home/progress-tab/grades/detailed-grades/DetailedGrades.jsx @@ -7,6 +7,7 @@ import { FormattedMessage, injectIntl, intlShape } from '@edx/frontend-platform/ import { Blocked } from '@openedx/paragon/icons'; import { Icon, Hyperlink } from '@openedx/paragon'; import { useModel } from '../../../../generic/model-store'; +import { showUngradedAssignments } from '../../utils'; import DetailedGradesTable from './DetailedGradesTable'; @@ -28,6 +29,8 @@ const DetailedGrades = ({ intl }) => { } = useModel('progress', courseId); const hasSectionScores = sectionScores.length > 0; + const emptyTableMsg = showUngradedAssignments() + ? messages.detailedGradesEmpty : messages.detailedGradesEmptyOnlyGraded; const logOutlineLinkClick = () => { sendTrackEvent('edx.ui.lms.course_progress.detailed_grades.course_outline_link.clicked', { @@ -54,7 +57,25 @@ const DetailedGrades = ({ intl }) => { return (
-

{intl.formatMessage(messages.detailedGrades)}

+

{intl.formatMessage(messages.detailedGrades)}

+ {gradesFeatureIsPartiallyLocked && (
@@ -65,9 +86,9 @@ const DetailedGrades = ({ intl }) => { )} {!hasSectionScores && ( -

{intl.formatMessage(messages.detailedGradesEmpty)}

+

{intl.formatMessage(emptyTableMsg)}

)} - {overviewTabUrl && ( + {overviewTabUrl && !showUngradedAssignments() && (

{ const { @@ -24,9 +25,10 @@ const DetailedGradesTable = ({ intl }) => { sectionScores.map((chapter) => { const subsectionScores = chapter.subsections.filter( (subsection) => !!( - subsection.hasGradedAssignment - && subsection.showGrades - && (subsection.numPointsPossible > 0 || subsection.numPointsEarned > 0)), + (showUngradedAssignments() || subsection.hasGradedAssignment) + && subsection.showGrades + && (subsection.numPointsPossible > 0 || subsection.numPointsEarned > 0) + ), ); if (subsectionScores.length === 0) { diff --git a/src/course-home/progress-tab/grades/detailed-grades/ProblemScoreDrawer.jsx b/src/course-home/progress-tab/grades/detailed-grades/ProblemScoreDrawer.jsx index 7970821aaf..9088c39f03 100644 --- a/src/course-home/progress-tab/grades/detailed-grades/ProblemScoreDrawer.jsx +++ b/src/course-home/progress-tab/grades/detailed-grades/ProblemScoreDrawer.jsx @@ -10,9 +10,12 @@ import messages from '../messages'; const ProblemScoreDrawer = ({ intl, problemScores, subsection }) => { const isLocaleRtl = isRtl(getLocale()); + + const scoreLabel = subsection.hasGradedAssignment ? messages.gradedScoreLabel : messages.practiceScoreLabel; + return ( - {intl.formatMessage(messages.problemScoreLabel)} + {intl.formatMessage(scoreLabel)}

    {problemScores.map((problemScore, i) => ( @@ -31,7 +34,10 @@ ProblemScoreDrawer.propTypes = { earned: PropTypes.number.isRequired, possible: PropTypes.number.isRequired, })).isRequired, - subsection: PropTypes.shape({ learnerHasAccess: PropTypes.bool }).isRequired, + subsection: PropTypes.shape({ + learnerHasAccess: PropTypes.bool, + hasGradedAssignment: PropTypes.bool, + }).isRequired, }; export default injectIntl(ProblemScoreDrawer); diff --git a/src/course-home/progress-tab/grades/messages.ts b/src/course-home/progress-tab/grades/messages.ts index 72a6624b2b..fb57809852 100644 --- a/src/course-home/progress-tab/grades/messages.ts +++ b/src/course-home/progress-tab/grades/messages.ts @@ -91,9 +91,14 @@ const messages = defineMessages({ defaultMessage: 'Detailed grades', description: 'Headline for the (detailed grade) section in the progress tab', }, - detailedGradesEmpty: { + detailedGradesEmptyOnlyGraded: { id: 'progress.detailedGrades.emptyTable', defaultMessage: 'You currently have no graded problem scores.', + description: 'It indicate that there are no graded problem or assignments to be scored', + }, + detailedGradesEmpty: { + id: 'progress.detailedGrades.including-ungraded.emptyTable', + defaultMessage: 'You currently have no graded or ungraded problem scores.', description: 'It indicate that there are no problem or assignments to be scored', }, footnotesTitle: { @@ -158,11 +163,16 @@ const messages = defineMessages({ defaultMessage: 'Passing grade', description: 'Label for mark on the (grade bar) chart which indicate the poisition of passing grade on the bar', }, - problemScoreLabel: { + gradedScoreLabel: { id: 'progress.detailedGrades.problemScore.label', - defaultMessage: 'Problem Scores:', + defaultMessage: 'Graded Scores:', description: 'Label text which precedes detailed view of all scores per assignment', }, + practiceScoreLabel: { + id: 'progress.detailedGrades.practice.problemScore.label', + defaultMessage: 'Practice Scores:', + description: 'Label text which precedes detailed view of all ungraded problem scores per assignment', + }, problemScoreToggleAltText: { id: 'progress.detailedGrades.problemScore.toggleButton', defaultMessage: 'Toggle individual problem scores for {subsectionTitle}', diff --git a/src/course-home/progress-tab/utils.ts b/src/course-home/progress-tab/utils.ts new file mode 100644 index 0000000000..29dd42de85 --- /dev/null +++ b/src/course-home/progress-tab/utils.ts @@ -0,0 +1,7 @@ +import { getConfig } from '@edx/frontend-platform'; + +/* eslint-disable import/prefer-default-export */ +export const showUngradedAssignments = () => ( + getConfig().SHOW_UNGRADED_ASSIGNMENT_PROGRESS === 'true' + || getConfig().SHOW_UNGRADED_ASSIGNMENT_PROGRESS === true +); diff --git a/src/courseware/CoursewareContainer.jsx b/src/courseware/CoursewareContainer.jsx index 309b72daf8..41aa01f227 100644 --- a/src/courseware/CoursewareContainer.jsx +++ b/src/courseware/CoursewareContainer.jsx @@ -168,7 +168,7 @@ class CoursewareContainer extends Component { checkFetchSequence = memoize((sequenceId) => { if (sequenceId) { - this.props.fetchSequence(sequenceId); + this.props.fetchSequence(sequenceId, this.props.isPreview); } }); diff --git a/src/courseware/course/Course.jsx b/src/courseware/course/Course.jsx index 3bfd91eca5..a5468fb3f4 100644 --- a/src/courseware/course/Course.jsx +++ b/src/courseware/course/Course.jsx @@ -34,6 +34,7 @@ const Course = ({ celebrations, isStaff, isNewDiscussionSidebarViewEnabled, + originalUserIsStaff, } = useModel('courseHomeMeta', courseId); const sequence = useModel('sequences', sequenceId); const section = useModel('sections', sequence ? sequence.sectionId : null); @@ -42,7 +43,7 @@ const Course = ({ const navigate = useNavigate(); const { pathname } = useLocation(); - if (!isStaff && pathname.startsWith('/preview')) { + if (!originalUserIsStaff && pathname.startsWith('/preview')) { const courseUrl = pathname.replace('/preview', ''); navigate(courseUrl, { replace: true }); } diff --git a/src/courseware/course/sequence/Sequence.jsx b/src/courseware/course/sequence/Sequence.jsx index dce4561bb2..69444eeb53 100644 --- a/src/courseware/course/sequence/Sequence.jsx +++ b/src/courseware/course/sequence/Sequence.jsx @@ -204,7 +204,7 @@ const Sequence = ({ sequenceId={sequenceId} unitId={unitId} unitLoadedHandler={handleUnitLoaded} - isStaff={isStaff} + isOriginalUserStaff={originalUserIsStaff} /> {unitHasLoaded && renderUnitNavigation(false)}
diff --git a/src/courseware/course/sequence/SequenceContent.jsx b/src/courseware/course/sequence/SequenceContent.jsx index 6c40c5bfd2..567883f5fe 100644 --- a/src/courseware/course/sequence/SequenceContent.jsx +++ b/src/courseware/course/sequence/SequenceContent.jsx @@ -15,7 +15,7 @@ const SequenceContent = ({ sequenceId, unitId, unitLoadedHandler, - isStaff, + isOriginalUserStaff, }) => { const intl = useIntl(); const sequence = useModel('sequences', sequenceId); @@ -60,7 +60,7 @@ const SequenceContent = ({ key={unitId} id={unitId} onLoaded={unitLoadedHandler} - isStaff={isStaff} + isOriginalUserStaff={isOriginalUserStaff} /> ); }; @@ -71,7 +71,7 @@ SequenceContent.propTypes = { sequenceId: PropTypes.string.isRequired, unitId: PropTypes.string, unitLoadedHandler: PropTypes.func.isRequired, - isStaff: PropTypes.bool.isRequired, + isOriginalUserStaff: PropTypes.bool.isRequired, }; SequenceContent.defaultProps = { diff --git a/src/courseware/course/sequence/Unit/index.jsx b/src/courseware/course/sequence/Unit/index.jsx index 71663cadc9..4933dedf99 100644 --- a/src/courseware/course/sequence/Unit/index.jsx +++ b/src/courseware/course/sequence/Unit/index.jsx @@ -22,7 +22,7 @@ const Unit = ({ format, onLoaded, id, - isStaff, + isOriginalUserStaff, }) => { const { formatMessage } = useIntl(); const [searchParams] = useSearchParams(); @@ -33,7 +33,7 @@ const Unit = ({ const unit = useModel(modelKeys.units, id); const isProcessing = unit.bookmarkedUpdateState === 'loading'; const view = authenticatedUser ? views.student : views.public; - const shouldDisplayUnitPreview = pathname.startsWith('/preview') && isStaff; + const shouldDisplayUnitPreview = pathname.startsWith('/preview') && isOriginalUserStaff; const getUrl = usePluginsCallback('getIFrameUrl', () => getIFrameUrl({ id, @@ -78,7 +78,7 @@ Unit.propTypes = { format: PropTypes.string, id: PropTypes.string.isRequired, onLoaded: PropTypes.func, - isStaff: PropTypes.bool.isRequired, + isOriginalUserStaff: PropTypes.bool.isRequired, }; Unit.defaultProps = { diff --git a/src/courseware/data/api.js b/src/courseware/data/api.js index b3b58baa76..199aa37cbc 100644 --- a/src/courseware/data/api.js +++ b/src/courseware/data/api.js @@ -36,9 +36,9 @@ export async function getCourseMetadata(courseId) { return normalizeMetadata(metadata); } -export async function getSequenceMetadata(sequenceId) { +export async function getSequenceMetadata(sequenceId, params) { const { data } = await getAuthenticatedHttpClient() - .get(`${getConfig().LMS_BASE_URL}/api/courseware/sequence/${sequenceId}`, {}); + .get(`${getConfig().LMS_BASE_URL}/api/courseware/sequence/${sequenceId}`, { params }); return normalizeSequenceMetadata(data); } diff --git a/src/courseware/data/thunks.js b/src/courseware/data/thunks.js index 7222d357bf..3f84fbf221 100644 --- a/src/courseware/data/thunks.js +++ b/src/courseware/data/thunks.js @@ -133,11 +133,11 @@ export function fetchCourse(courseId) { }; } -export function fetchSequence(sequenceId) { +export function fetchSequence(sequenceId, isPreview) { return async (dispatch) => { dispatch(fetchSequenceRequest({ sequenceId })); try { - const { sequence, units } = await getSequenceMetadata(sequenceId); + const { sequence, units } = await getSequenceMetadata(sequenceId, { preview: isPreview ? '1' : '0' }); if (sequence.blockType !== 'sequential') { // Some other block types (particularly 'chapter') can be returned // by this API. We want to error in that case, since downstream diff --git a/src/index.jsx b/src/index.jsx index 9680c134d4..ec4f4dc284 100755 --- a/src/index.jsx +++ b/src/index.jsx @@ -173,6 +173,7 @@ initialize({ PROCTORED_EXAM_RULES_URL: process.env.PROCTORED_EXAM_RULES_URL || null, CHAT_RESPONSE_URL: process.env.CHAT_RESPONSE_URL || null, PRIVACY_POLICY_URL: process.env.PRIVACY_POLICY_URL || null, + SHOW_UNGRADED_ASSIGNMENT_PROGRESS: process.env.SHOW_UNGRADED_ASSIGNMENT_PROGRESS || false, }, 'LearnerAppConfig'); }, },