From cb80a6b4de8b3ac2fdb49646b6d109ce501f22ea Mon Sep 17 00:00:00 2001 From: vladislavkeblysh Date: Wed, 5 Mar 2025 11:31:15 +0200 Subject: [PATCH 1/3] feat: increased font-size for Progress page --- .../certificate-status/CertificateStatus.jsx | 9 ++- .../course-completion/CourseCompletion.jsx | 8 ++- .../course-completion/CourseCompletion.scss | 3 + .../credit-information/CreditInformation.jsx | 4 +- .../grades/course-grade/CourseGrade.jsx | 7 ++- .../grades/detailed-grades/DetailedGrades.jsx | 13 +++- .../DroppableAssignmentFootnote.jsx | 8 ++- .../grade-summary/GradeSummaryHeader.jsx | 37 ++++++++---- .../grade-summary/GradeSummaryHeader.test.jsx | 60 +++++++++++++++++++ src/course-home/progress-tab/index.scss | 6 ++ .../related-links/RelatedLinks.jsx | 19 +++--- .../related-links/RelatedLinks.scss | 9 +++ src/index.scss | 7 +-- 13 files changed, 158 insertions(+), 32 deletions(-) create mode 100644 src/course-home/progress-tab/course-completion/CourseCompletion.scss create mode 100644 src/course-home/progress-tab/grades/grade-summary/GradeSummaryHeader.test.jsx create mode 100644 src/course-home/progress-tab/index.scss create mode 100644 src/course-home/progress-tab/related-links/RelatedLinks.scss diff --git a/src/course-home/progress-tab/certificate-status/CertificateStatus.jsx b/src/course-home/progress-tab/certificate-status/CertificateStatus.jsx index a4ac7da7b2..bd0715a011 100644 --- a/src/course-home/progress-tab/certificate-status/CertificateStatus.jsx +++ b/src/course-home/progress-tab/certificate-status/CertificateStatus.jsx @@ -1,10 +1,13 @@ import { useEffect } from 'react'; +import classNames from 'classnames'; import { useDispatch } from 'react-redux'; import { sendTrackEvent } from '@edx/frontend-platform/analytics'; import { getAuthenticatedUser } from '@edx/frontend-platform/auth'; import { FormattedDate, FormattedMessage, useIntl } from '@edx/frontend-platform/i18n'; -import { Button, Card } from '@openedx/paragon'; +import { + Button, Card, breakpoints, useWindowSize, +} from '@openedx/paragon'; import { getConfig } from '@edx/frontend-platform'; import { useContextId } from '../../../data/hooks'; import { useModel } from '../../../generic/model-store'; @@ -29,6 +32,8 @@ const CertificateStatus = () => { userTimezone, } = useModel('courseHomeMeta', courseId); + const wideScreen = useWindowSize().width >= breakpoints.medium.minWidth; + const { certificateData, end, @@ -244,7 +249,7 @@ const CertificateStatus = () => {
- + {body} diff --git a/src/course-home/progress-tab/course-completion/CourseCompletion.jsx b/src/course-home/progress-tab/course-completion/CourseCompletion.jsx index 4f85a081dc..d61a7226a2 100644 --- a/src/course-home/progress-tab/course-completion/CourseCompletion.jsx +++ b/src/course-home/progress-tab/course-completion/CourseCompletion.jsx @@ -1,17 +1,21 @@ +import React from 'react'; import { useIntl } from '@edx/frontend-platform/i18n'; +import classNames from 'classnames'; +import { breakpoints, useWindowSize } from '@openedx/paragon'; import CompletionDonutChart from './CompletionDonutChart'; import messages from './messages'; const CourseCompletion = () => { const intl = useIntl(); + const wideScreen = useWindowSize().width >= breakpoints.medium.minWidth; return ( -
+

{intl.formatMessage(messages.courseCompletion)}

-

+

{intl.formatMessage(messages.completionBody)}

diff --git a/src/course-home/progress-tab/course-completion/CourseCompletion.scss b/src/course-home/progress-tab/course-completion/CourseCompletion.scss new file mode 100644 index 0000000000..8bb4b60ae9 --- /dev/null +++ b/src/course-home/progress-tab/course-completion/CourseCompletion.scss @@ -0,0 +1,3 @@ +.course-completion-text { + font-size: 18px; +} diff --git a/src/course-home/progress-tab/credit-information/CreditInformation.jsx b/src/course-home/progress-tab/credit-information/CreditInformation.jsx index c12c4de2ec..ce824f28f3 100644 --- a/src/course-home/progress-tab/credit-information/CreditInformation.jsx +++ b/src/course-home/progress-tab/credit-information/CreditInformation.jsx @@ -61,7 +61,7 @@ const CreditInformation = () => { requirementStatus = (<>{intl.formatMessage(messages.upcoming)} ); } requirements.push(( -
+

{requirement.namespace === 'grade' ? `${intl.formatMessage(messages.minimumGrade, { minGrade: Number(requirement.criteria.minGrade) * 100 })}:` @@ -77,7 +77,7 @@ const CreditInformation = () => { return ( <>

{intl.formatMessage(messages.requirementsHeader)}

-

{eligibilityStatus}

+

{eligibilityStatus}

{requirements} ); diff --git a/src/course-home/progress-tab/grades/course-grade/CourseGrade.jsx b/src/course-home/progress-tab/grades/course-grade/CourseGrade.jsx index d69c6eee15..878340dd2d 100644 --- a/src/course-home/progress-tab/grades/course-grade/CourseGrade.jsx +++ b/src/course-home/progress-tab/grades/course-grade/CourseGrade.jsx @@ -1,4 +1,7 @@ +import React from 'react'; import { useIntl } from '@edx/frontend-platform/i18n'; +import classNames from 'classnames'; +import { breakpoints, useWindowSize } from '@openedx/paragon'; import { useContextId } from '../../../../data/hooks'; import { useModel } from '../../../../generic/model-store'; @@ -14,6 +17,8 @@ const CourseGrade = () => { const intl = useIntl(); const courseId = useContextId(); + const wideScreen = useWindowSize().width >= breakpoints.medium.minWidth; + const { creditCourseRequirements, gradesFeatureIsFullyLocked, @@ -37,7 +42,7 @@ const CourseGrade = () => { ? intl.formatMessage(messages.gradesAndCredit) : intl.formatMessage(messages.grades)} -

+

{intl.formatMessage(messages.courseGradeBody)}

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 6b61869888..3fc0e33a66 100644 --- a/src/course-home/progress-tab/grades/detailed-grades/DetailedGrades.jsx +++ b/src/course-home/progress-tab/grades/detailed-grades/DetailedGrades.jsx @@ -1,8 +1,12 @@ +import React from 'react'; +import classNames from 'classnames'; import { sendTrackEvent } from '@edx/frontend-platform/analytics'; import { getAuthenticatedUser } from '@edx/frontend-platform/auth'; import { useIntl } from '@edx/frontend-platform/i18n'; import { Locked } from '@openedx/paragon/icons'; -import { Icon, Hyperlink } from '@openedx/paragon'; +import { + Icon, Hyperlink, breakpoints, useWindowSize, +} from '@openedx/paragon'; import { useContextId } from '../../../../data/hooks'; import { useModel } from '../../../../generic/model-store'; import { showUngradedAssignments } from '../../utils'; @@ -24,6 +28,7 @@ const DetailedGrades = () => { gradesFeatureIsPartiallyLocked, sectionScores, } = useModel('progress', courseId); + const wideScreen = useWindowSize().width >= breakpoints.medium.minWidth; const hasSectionScores = sectionScores.length > 0; const emptyTableMsg = showUngradedAssignments() @@ -75,10 +80,12 @@ const DetailedGrades = () => { )} {!hasSectionScores && ( -

{intl.formatMessage(emptyTableMsg)}

+

+ {intl.formatMessage(emptyTableMsg)} +

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

+

{intl.formatMessage(messages.ungradedAlert, { outlineLink })}

)} diff --git a/src/course-home/progress-tab/grades/grade-summary/DroppableAssignmentFootnote.jsx b/src/course-home/progress-tab/grades/grade-summary/DroppableAssignmentFootnote.jsx index 199fbb42f4..2a1a1158a6 100644 --- a/src/course-home/progress-tab/grades/grade-summary/DroppableAssignmentFootnote.jsx +++ b/src/course-home/progress-tab/grades/grade-summary/DroppableAssignmentFootnote.jsx @@ -1,4 +1,8 @@ +import React from 'react'; import PropTypes from 'prop-types'; +import classNames from 'classnames'; + +import { breakpoints, useWindowSize } from '@openedx/paragon'; import { useIntl } from '@edx/frontend-platform/i18n'; import { useContextId } from '../../../../data/hooks'; @@ -12,12 +16,14 @@ const DroppableAssignmentFootnote = ({ footnotes }) => { const { gradesFeatureIsFullyLocked, } = useModel('progress', courseId); + const wideScreen = useWindowSize().width >= breakpoints.medium.minWidth; + return ( <> {intl.formatMessage(messages.footnotesTitle)}
    {footnotes.map((footnote, index) => ( -
  • +
  • {index + 1} {intl.formatMessage(messages.droppableAssignmentsText, { numDroppable: footnote.numDroppable, diff --git a/src/course-home/progress-tab/grades/grade-summary/GradeSummaryHeader.jsx b/src/course-home/progress-tab/grades/grade-summary/GradeSummaryHeader.jsx index df1ff65836..3ceee906e2 100644 --- a/src/course-home/progress-tab/grades/grade-summary/GradeSummaryHeader.jsx +++ b/src/course-home/progress-tab/grades/grade-summary/GradeSummaryHeader.jsx @@ -1,15 +1,13 @@ +import React, { useState } from 'react'; +import classNames from 'classnames'; import PropTypes from 'prop-types'; import { useIntl } from '@edx/frontend-platform/i18n'; import { - Hyperlink, - Icon, - OverlayTrigger, - Stack, - Tooltip, + Icon, IconButton, OverlayTrigger, Popover, breakpoints, useWindowSize, Stack, Hyperlink, } from '@openedx/paragon'; import { InfoOutline, Locked } from '@openedx/paragon/icons'; -import { useContextId } from '../../../../data/hooks'; +import { useContextId } from '../../../../data/hooks'; import messages from '../messages'; import { useModel } from '../../../../generic/model-store'; @@ -21,6 +19,15 @@ const GradeSummaryHeader = ({ allOfSomeAssignmentTypeIsLocked }) => { gradesFeatureIsFullyLocked, } = useModel('progress', courseId); + const [showTooltip, setShowTooltip] = useState(false); + const wideScreen = useWindowSize().width >= breakpoints.medium.minWidth; + + const handleKeyDown = (e) => { + if (e.key === 'Escape') { + setShowTooltip(false); + } + }; + return ( @@ -29,15 +36,25 @@ const GradeSummaryHeader = ({ allOfSomeAssignmentTypeIsLocked }) => { trigger="hover" placement="top" overlay={( - - {intl.formatMessage(messages.gradeSummaryTooltipBody)} - + + + {intl.formatMessage(messages.gradeSummaryTooltipBody)} + + )} > - { setShowTooltip(!showTooltip); }} + onBlur={() => { setShowTooltip(false); }} + onKeyDown={handleKeyDown} alt={intl.formatMessage(messages.gradeSummaryTooltipAlt)} src={InfoOutline} + iconAs={Icon} + className="mb-3" size="sm" + disabled={gradesFeatureIsFullyLocked} /> diff --git a/src/course-home/progress-tab/grades/grade-summary/GradeSummaryHeader.test.jsx b/src/course-home/progress-tab/grades/grade-summary/GradeSummaryHeader.test.jsx new file mode 100644 index 0000000000..33e11fda65 --- /dev/null +++ b/src/course-home/progress-tab/grades/grade-summary/GradeSummaryHeader.test.jsx @@ -0,0 +1,60 @@ +import React from 'react'; +import { + render, screen, waitFor, +} from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { useSelector } from 'react-redux'; +import { IntlProvider } from 'react-intl'; + +import GradeSummaryHeader from './GradeSummaryHeader'; +import { useModel } from '../../../../generic/model-store'; +import messages from '../messages'; + +jest.mock('react-redux', () => ({ + useSelector: jest.fn(), +})); + +jest.mock('../../../../generic/model-store', () => ({ + useModel: jest.fn(), +})); + +describe('GradeSummaryHeader', () => { + beforeEach(() => { + useSelector.mockImplementation((selector) => selector({ + courseHome: { courseId: 'test-course-id' }, + })); + useModel.mockReturnValue({ gradesFeatureIsFullyLocked: false }); + }); + + const renderComponent = (props = {}) => { + render( + + msg.defaultMessage) }} + allOfSomeAssignmentTypeIsLocked={false} + {...props} + /> + , + ); + }; + + it('opens and closes the tooltip when Escape is pressed', async () => { + renderComponent(); + + const iconButton = screen.getByRole('button', { + name: messages.gradeSummaryTooltipAlt.defaultMessage, + }); + + userEvent.click(iconButton); + + await waitFor(() => { + expect(screen.getByText(messages.gradeSummaryTooltipBody.defaultMessage)).toBeVisible(); + }); + + userEvent.keyboard('{Escape}'); + + await waitFor(() => { + expect(screen.queryByText(messages.gradeSummaryTooltipBody.defaultMessage)).not.toBeInTheDocument(); + }); + }); +}); diff --git a/src/course-home/progress-tab/index.scss b/src/course-home/progress-tab/index.scss new file mode 100644 index 0000000000..709e9787ab --- /dev/null +++ b/src/course-home/progress-tab/index.scss @@ -0,0 +1,6 @@ +@import "course-completion/CompletionDonutChart.scss"; +@import "course-completion/CourseCompletion.scss"; + +@import "grades/course-grade/GradeBar.scss"; + +@import "related-links/RelatedLinks.scss"; diff --git a/src/course-home/progress-tab/related-links/RelatedLinks.jsx b/src/course-home/progress-tab/related-links/RelatedLinks.jsx index cf0c27db4a..a3231800f7 100644 --- a/src/course-home/progress-tab/related-links/RelatedLinks.jsx +++ b/src/course-home/progress-tab/related-links/RelatedLinks.jsx @@ -1,9 +1,11 @@ import { sendTrackEvent } from '@edx/frontend-platform/analytics'; +import React from 'react'; +import classNames from 'classnames'; import { getAuthenticatedUser } from '@edx/frontend-platform/auth'; import { useIntl } from '@edx/frontend-platform/i18n'; -import { Hyperlink } from '@openedx/paragon'; -import { useContextId } from '../../../data/hooks'; +import { Hyperlink, breakpoints, useWindowSize } from '@openedx/paragon'; +import { useContextId } from '../../../data/hooks'; import messages from './messages'; import { useModel } from '../../../generic/model-store'; @@ -14,6 +16,7 @@ const RelatedLinks = () => { org, tabs, } = useModel('courseHomeMeta', courseId); + const wideScreen = useWindowSize().width >= breakpoints.medium.minWidth; const { administrator } = getAuthenticatedUser(); const logLinkClicked = (linkName) => { @@ -31,11 +34,13 @@ const RelatedLinks = () => { const datesTabUrl = datesTab && datesTab.url; return ( -
    -

    {intl.formatMessage(messages.relatedLinks)}

    -
      +
      +

      + {intl.formatMessage(messages.relatedLinks)} +

      +
        {datesTabUrl && ( -
      • +
      • logLinkClicked('dates')}> {intl.formatMessage(messages.datesCardLink)} @@ -43,7 +48,7 @@ const RelatedLinks = () => {
      • )} {overviewTabUrl && ( -
      • +
      • logLinkClicked('course_outline')}> {intl.formatMessage(messages.outlineCardLink)} diff --git a/src/course-home/progress-tab/related-links/RelatedLinks.scss b/src/course-home/progress-tab/related-links/RelatedLinks.scss new file mode 100644 index 0000000000..46817aeeb5 --- /dev/null +++ b/src/course-home/progress-tab/related-links/RelatedLinks.scss @@ -0,0 +1,9 @@ +.related-links { + .related-links-title { + font-size: 20px; + } + + .related-links-list .related-links-list-item { + font-size: 18px; + } +} diff --git a/src/index.scss b/src/index.scss index f4ae867e15..17f16f1486 100755 --- a/src/index.scss +++ b/src/index.scss @@ -446,12 +446,12 @@ .course-outline-tab .pgn__card { .pgn__card-header { display: block; - + .pgn__card-header-content { margin-top: 0; } } - + .pgn__card-header-actions { margin-left: 0; } @@ -468,8 +468,7 @@ @import "course-home/dates-tab/timeline/Day.scss"; @import "course-home/outline-tab/widgets/ProctoringInfoPanel.scss"; @import "course-home/outline-tab/widgets/FlagButton.scss"; -@import "course-home/progress-tab/course-completion/CompletionDonutChart.scss"; -@import "course-home/progress-tab/grades/course-grade/GradeBar.scss"; +@import "course-home/progress-tab"; @import "courseware/course/course-exit/CourseRecommendations"; @import "product-tours/newUserCourseHomeTour/NewUserCourseHomeTourModal.scss"; @import "course-home/courseware-search/courseware-search.scss"; From da34419756c6e21af2c11d6e795e38de0ee1f3a4 Mon Sep 17 00:00:00 2001 From: vladislavkeblysh Date: Tue, 15 Apr 2025 17:17:02 +0300 Subject: [PATCH 2/3] feat: refactor tests --- src/course-home/live-tab/LiveTab.test.jsx | 77 +++++++++++++++++++++++ 1 file changed, 77 insertions(+) create mode 100644 src/course-home/live-tab/LiveTab.test.jsx diff --git a/src/course-home/live-tab/LiveTab.test.jsx b/src/course-home/live-tab/LiveTab.test.jsx new file mode 100644 index 0000000000..71ffb4f4ba --- /dev/null +++ b/src/course-home/live-tab/LiveTab.test.jsx @@ -0,0 +1,77 @@ +import React from 'react'; +import { render } from '@testing-library/react'; +import { useSelector } from 'react-redux'; +import LiveTab from './LiveTab'; + +jest.mock('react-redux', () => ({ + useSelector: jest.fn(), +})); + +describe('LiveTab', () => { + afterEach(() => { + jest.clearAllMocks(); + document.body.innerHTML = ''; + }); + + it('renders iframe from liveModel using dangerouslySetInnerHTML', () => { + useSelector.mockImplementation((selector) => selector({ + courseHome: { courseId: 'course-v1:test+id+2024' }, + models: { + live: { + 'course-v1:test+id+2024': { + iframe: '', + }, + }, + }, + })); + + render(); + + const iframe = document.getElementById('lti-tab-embed'); + expect(iframe).toBeInTheDocument(); + expect(iframe.src).toBe('about:blank'); + }); + + it('adds classes to iframe after mount', () => { + document.body.innerHTML = ` +
        + +
        + `; + + useSelector.mockImplementation((selector) => selector({ + courseHome: { courseId: 'course-v1:test+id+2024' }, + models: { + live: { + 'course-v1:test+id+2024': { + iframe: '', + }, + }, + }, + })); + + render(); + + const iframe = document.getElementById('lti-tab-embed'); + expect(iframe.className).toContain('vh-100'); + expect(iframe.className).toContain('w-100'); + expect(iframe.className).toContain('border-0'); + }); + + it('does not throw if iframe is not found in DOM', () => { + useSelector.mockImplementation((selector) => selector({ + courseHome: { courseId: 'course-v1:test+id+2024' }, + models: { + live: { + 'course-v1:test+id+2024': { + iframe: '
        No iframe here
        ', + }, + }, + }, + })); + + expect(() => render()).not.toThrow(); + const iframe = document.getElementById('lti-tab-embed'); + expect(iframe).toBeNull(); + }); +}); From cf6ea3b0d6d975989d11eee2cd323d3105db1a75 Mon Sep 17 00:00:00 2001 From: vladislavkeblysh Date: Tue, 15 Apr 2025 17:29:51 +0300 Subject: [PATCH 3/3] feat: refactor tests --- .../grade-summary/GradeSummaryHeader.test.jsx | 81 +++++++++++++++++-- 1 file changed, 74 insertions(+), 7 deletions(-) diff --git a/src/course-home/progress-tab/grades/grade-summary/GradeSummaryHeader.test.jsx b/src/course-home/progress-tab/grades/grade-summary/GradeSummaryHeader.test.jsx index 33e11fda65..39eb1aa6da 100644 --- a/src/course-home/progress-tab/grades/grade-summary/GradeSummaryHeader.test.jsx +++ b/src/course-home/progress-tab/grades/grade-summary/GradeSummaryHeader.test.jsx @@ -1,11 +1,10 @@ import React from 'react'; -import { - render, screen, waitFor, -} from '@testing-library/react'; +import { render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { useSelector } from 'react-redux'; import { IntlProvider } from 'react-intl'; +import { fireEvent } from '@testing-library/dom'; import GradeSummaryHeader from './GradeSummaryHeader'; import { useModel } from '../../../../generic/model-store'; import messages from '../messages'; @@ -18,6 +17,10 @@ jest.mock('../../../../generic/model-store', () => ({ useModel: jest.fn(), })); +jest.mock('../../../../data/hooks', () => ({ + useContextId: () => 'test-course-id', +})); + describe('GradeSummaryHeader', () => { beforeEach(() => { useSelector.mockImplementation((selector) => selector({ @@ -30,7 +33,6 @@ describe('GradeSummaryHeader', () => { render( msg.defaultMessage) }} allOfSomeAssignmentTypeIsLocked={false} {...props} /> @@ -38,20 +40,85 @@ describe('GradeSummaryHeader', () => { ); }; - it('opens and closes the tooltip when Escape is pressed', async () => { + it('shows tooltip on icon button click', async () => { + renderComponent(); + + const iconButton = screen.getByRole('button', { + name: messages.gradeSummaryTooltipAlt.defaultMessage, + }); + + await userEvent.click(iconButton); + + await waitFor(() => { + expect(screen.getByText(messages.gradeSummaryTooltipBody.defaultMessage)).toBeInTheDocument(); + }); + }); + + it('hides tooltip on mouse out', async () => { renderComponent(); const iconButton = screen.getByRole('button', { name: messages.gradeSummaryTooltipAlt.defaultMessage, }); - userEvent.click(iconButton); + fireEvent.mouseOver(iconButton); await waitFor(() => { expect(screen.getByText(messages.gradeSummaryTooltipBody.defaultMessage)).toBeVisible(); }); - userEvent.keyboard('{Escape}'); + fireEvent.mouseOut(iconButton); + + await waitFor(() => { + expect(screen.queryByText(messages.gradeSummaryTooltipBody.defaultMessage)).toBeNull(); + }); + }); + + it('hides tooltip on blur', async () => { + renderComponent(); + + const iconButton = screen.getByRole('button', { + name: messages.gradeSummaryTooltipAlt.defaultMessage, + }); + + await userEvent.hover(iconButton); + await userEvent.click(iconButton); + + await waitFor(() => { + expect(screen.getByText(messages.gradeSummaryTooltipBody.defaultMessage)).toBeInTheDocument(); + }); + + const blurTarget = document.createElement('button'); + blurTarget.textContent = 'Outside'; + document.body.appendChild(blurTarget); + blurTarget.focus(); + + await userEvent.unhover(iconButton); + + await waitFor(() => { + expect(screen.queryByText(messages.gradeSummaryTooltipBody.defaultMessage)).not.toBeInTheDocument(); + }); + + document.body.removeChild(blurTarget); + }); + + it('hides tooltip when Escape is pressed (covers handleKeyDown)', async () => { + renderComponent(); + + const iconButton = screen.getByRole('button', { + name: messages.gradeSummaryTooltipAlt.defaultMessage, + }); + + await userEvent.hover(iconButton); + await userEvent.click(iconButton); + + await waitFor(() => { + expect(screen.getByText(messages.gradeSummaryTooltipBody.defaultMessage)).toBeInTheDocument(); + }); + + fireEvent.keyDown(iconButton, { key: 'Escape', code: 'Escape' }); + + await userEvent.unhover(iconButton); await waitFor(() => { expect(screen.queryByText(messages.gradeSummaryTooltipBody.defaultMessage)).not.toBeInTheDocument();