From a6306bdccb46f6ca2da1c70e19e25b2927e84b77 Mon Sep 17 00:00:00 2001 From: vladislavkeblysh Date: Wed, 12 Mar 2025 15:16:07 +0200 Subject: [PATCH 1/2] feat: improved accessibility for Show/Hide Accordions --- .../outline-tab/OutlineTab.test.jsx | 18 +++ src/course-home/outline-tab/Section.jsx | 139 ++++++++++++++++++ src/course-home/outline-tab/Section.scss | 4 + src/index.scss | 7 +- 4 files changed, 166 insertions(+), 2 deletions(-) create mode 100644 src/course-home/outline-tab/Section.jsx create mode 100644 src/course-home/outline-tab/Section.scss diff --git a/src/course-home/outline-tab/OutlineTab.test.jsx b/src/course-home/outline-tab/OutlineTab.test.jsx index 8a95e66ae7..c843d4ffd2 100644 --- a/src/course-home/outline-tab/OutlineTab.test.jsx +++ b/src/course-home/outline-tab/OutlineTab.test.jsx @@ -132,6 +132,24 @@ describe('Outline Tab', () => { expect(expandedSectionNode).toHaveAttribute('aria-expanded', 'true'); }); + it('displays correct heading for expanded section', async () => { + const { courseBlocks } = await buildMinimalCourseBlocks(courseId, 'Title', { resumeBlock: true }); + setTabData({ course_blocks: { blocks: courseBlocks.blocks } }); + await fetchAndRender(); + const headingContent = screen.getByText('Title of Section'); + const { parentElement } = headingContent; + expect(parentElement.tagName).toBe('H2'); + }); + + it('checks that the expanded section is within the correct list', async () => { + const { courseBlocks } = await buildMinimalCourseBlocks(courseId, 'Title', { resumeBlock: true }); + setTabData({ course_blocks: { blocks: courseBlocks.blocks } }); + await fetchAndRender(); + const listElement = screen.getByRole('presentation', { id: 'courseHome-outline' }); + expect(listElement).toBeInTheDocument(); + expect(listElement.tagName).toBe('OL'); + }); + it('includes outline_tab_notifications_slot', async () => { const { courseBlocks } = await buildMinimalCourseBlocks(courseId, 'Title', { resumeBlock: true }); setTabData({ diff --git a/src/course-home/outline-tab/Section.jsx b/src/course-home/outline-tab/Section.jsx new file mode 100644 index 0000000000..b60eb826f1 --- /dev/null +++ b/src/course-home/outline-tab/Section.jsx @@ -0,0 +1,139 @@ +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/Section.scss b/src/course-home/outline-tab/Section.scss new file mode 100644 index 0000000000..8cf4e666af --- /dev/null +++ b/src/course-home/outline-tab/Section.scss @@ -0,0 +1,4 @@ +.course-outline-tab-section-title { + font-size: $font-size-base; + line-height: $line-height-base; +} diff --git a/src/index.scss b/src/index.scss index f4ae867e15..dafac929c3 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; } @@ -466,6 +466,9 @@ @import "courseware/course/content-tools/calculator/calculator.scss"; @import "courseware/course/content-tools/contentTools.scss"; @import "course-home/dates-tab/timeline/Day.scss"; +@import "course-home/outline-tab/Section.scss"; +@import "generic/upgrade-notification/UpgradeNotification.scss"; +@import "generic/upsell-bullets/UpsellBullets.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"; From 801def105275fb44a6faf454966bc02769daab6b Mon Sep 17 00:00:00 2001 From: vladislavkeblysh Date: Tue, 15 Apr 2025 15:22:47 +0300 Subject: [PATCH 2/2] feat: refactor after rebase --- src/course-home/live-tab/LiveTab.jsx | 1 + src/course-home/live-tab/LiveTab.test.jsx | 77 ++++++++++ src/course-home/outline-tab/Section.jsx | 139 ------------------ .../section-outline/SectionTitle.tsx | 4 +- src/index.scss | 1 - .../CourseHomeSectionOutlineSlot/index.tsx | 2 +- 6 files changed, 82 insertions(+), 142 deletions(-) create mode 100644 src/course-home/live-tab/LiveTab.test.jsx delete mode 100644 src/course-home/outline-tab/Section.jsx diff --git a/src/course-home/live-tab/LiveTab.jsx b/src/course-home/live-tab/LiveTab.jsx index 05a470e038..eccbf60db9 100644 --- a/src/course-home/live-tab/LiveTab.jsx +++ b/src/course-home/live-tab/LiveTab.jsx @@ -13,6 +13,7 @@ const LiveTab = () => { return (
    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/outline-tab/Section.jsx b/src/course-home/outline-tab/Section.jsx deleted file mode 100644 index b60eb826f1..0000000000 --- a/src/course-home/outline-tab/Section.jsx +++ /dev/null @@ -1,139 +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/section-outline/SectionTitle.tsx b/src/course-home/outline-tab/section-outline/SectionTitle.tsx index 69c4ddfd98..057e9ade3c 100644 --- a/src/course-home/outline-tab/section-outline/SectionTitle.tsx +++ b/src/course-home/outline-tab/section-outline/SectionTitle.tsx @@ -35,7 +35,9 @@ const SectionTitle: React.FC = ({ complete, hideFromTOC, title }) => { )}
    - {title} +

    + {title} +

    , {intl.formatMessage(complete ? messages.completedSection : messages.incompleteSection)} diff --git a/src/index.scss b/src/index.scss index dafac929c3..9f314c97c2 100755 --- a/src/index.scss +++ b/src/index.scss @@ -467,7 +467,6 @@ @import "courseware/course/content-tools/contentTools.scss"; @import "course-home/dates-tab/timeline/Day.scss"; @import "course-home/outline-tab/Section.scss"; -@import "generic/upgrade-notification/UpgradeNotification.scss"; @import "generic/upsell-bullets/UpsellBullets.scss"; @import "course-home/outline-tab/widgets/ProctoringInfoPanel.scss"; @import "course-home/outline-tab/widgets/FlagButton.scss"; diff --git a/src/plugin-slots/CourseHomeSectionOutlineSlot/index.tsx b/src/plugin-slots/CourseHomeSectionOutlineSlot/index.tsx index 5d7ae6e0ee..0e3d65e9ac 100644 --- a/src/plugin-slots/CourseHomeSectionOutlineSlot/index.tsx +++ b/src/plugin-slots/CourseHomeSectionOutlineSlot/index.tsx @@ -15,7 +15,7 @@ const CourseHomeSectionOutlineSlot: React.FC = ({ id="course_home_section_outline_slot" pluginProps={{ expandAll, sectionIds, sections }} > -
      +