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(); + }); +}); 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..39eb1aa6da --- /dev/null +++ b/src/course-home/progress-tab/grades/grade-summary/GradeSummaryHeader.test.jsx @@ -0,0 +1,127 @@ +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 { fireEvent } from '@testing-library/dom'; +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(), +})); + +jest.mock('../../../../data/hooks', () => ({ + useContextId: () => 'test-course-id', +})); + +describe('GradeSummaryHeader', () => { + beforeEach(() => { + useSelector.mockImplementation((selector) => selector({ + courseHome: { courseId: 'test-course-id' }, + })); + useModel.mockReturnValue({ gradesFeatureIsFullyLocked: false }); + }); + + const renderComponent = (props = {}) => { + render( + + + , + ); + }; + + 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, + }); + + fireEvent.mouseOver(iconButton); + + await waitFor(() => { + expect(screen.getByText(messages.gradeSummaryTooltipBody.defaultMessage)).toBeVisible(); + }); + + 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(); + }); + }); +}); 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";