From 4af3f0e34a4c7f5dde4459e1f31bd7edaf0036b0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=B4mulo=20Penido?= Date: Fri, 16 Jan 2026 19:04:56 -0300 Subject: [PATCH 01/12] fix: update to new course outline structure and design --- src/components/AspectsSidebar/index.test.tsx | 31 ++-------- src/components/AspectsSidebar/index.tsx | 37 ++---------- src/components/CourseHeaderButton.tsx | 31 ---------- src/components/CourseOutlineSidebar.test.tsx | 59 +++++++++++++++++-- src/components/CourseOutlineSidebar.tsx | 37 +++++++++++- src/components/SidebarToggleWrapper.tsx | 11 ---- .../SubSectionAnalyticsButton.test.tsx | 5 -- src/components/SubSectionAnalyticsButton.tsx | 7 +-- src/components/UnitActionsButton.tsx | 6 +- src/hooks.ts | 7 --- src/index.ts | 4 +- src/messages.ts | 5 -- src/plugin-slots.ts | 50 ++-------------- 13 files changed, 105 insertions(+), 185 deletions(-) delete mode 100644 src/components/CourseHeaderButton.tsx delete mode 100644 src/components/SidebarToggleWrapper.tsx diff --git a/src/components/AspectsSidebar/index.test.tsx b/src/components/AspectsSidebar/index.test.tsx index bb79f072..34ecd3d5 100644 --- a/src/components/AspectsSidebar/index.test.tsx +++ b/src/components/AspectsSidebar/index.test.tsx @@ -71,7 +71,6 @@ const defaultProps = { }; type InitialState = { - sidebarOpen: boolean, activeBlock?: Block | null, filterUnit?: Block | null, filteredBlocks?: string[] @@ -79,17 +78,12 @@ type InitialState = { // Helper component to set context values for tests function TestContextHelper({ - sidebarOpen = true, activeBlock = null, filterUnit = null, filteredBlocks = [], + activeBlock = null, filterUnit = null, filteredBlocks = [], }: InitialState) { const { - setSidebarOpen, setActiveBlock, setFilterUnit, setFilteredBlocks, + setActiveBlock, setFilterUnit, setFilteredBlocks, } = useAspectsSidebarContext(); - // This effect is run only one to set the initial state for the tests. - React.useEffect(() => { - setSidebarOpen(sidebarOpen); - }, []); // eslint-disable-line react-hooks/exhaustive-deps - // The sidebar will clear active/filter stuff to render cleanly on initalization // So, we can't set these values in the useEffect above. The only way to cleanly // do it is by userEvent.click - simulating real-world scenario @@ -109,7 +103,7 @@ function TestContextHelper({ describe('AspectsSidebar', () => { const renderComponent = ( - initialState: InitialState = { sidebarOpen: true }, + initialState: InitialState = {}, props?: Partial>, ) => render( @@ -126,19 +120,6 @@ describe('AspectsSidebar', () => { jest.clearAllMocks(); }); - it('closes the sidebar when the close button is clicked', async () => { - renderComponent(); - // Ensure the sidebar is initially open and visible - expect(screen.getByTestId('sidebar')).toBeVisible(); - expect(screen.getByTestId('sidebar-title')).toHaveTextContent('Test Course'); - - const closeButton = screen.getByRole('button', { name: 'Close' }); - fireEvent.click(closeButton); - - // Assert that the sidebar is no longer visible - expect(screen.queryByTestId('sidebar')).not.toBeInTheDocument(); - }); - it('shows the back button and changes the title when a block is clicked', () => { renderComponent(); @@ -168,7 +149,7 @@ describe('AspectsSidebar', () => { it('shows an Alert message when the content lists are empty on a Unit Page', () => { // NOTE: Currently there is not "Unit Page Dashboard", hence the alert - renderComponent({ sidebarOpen: true }, { blockType: BlockTypes.vertical, contentLists: [] }); + renderComponent(undefined, { blockType: BlockTypes.vertical, contentLists: [] }); expect(mockDashboard).not.toHaveBeenCalled(); expect(screen.getByRole('alert')).toHaveTextContent(messages.noAnalyticsForUnit.defaultMessage); @@ -177,7 +158,6 @@ describe('AspectsSidebar', () => { it('shows filtered set of components in the Course Outline view when specific unit is selected', () => { // render the component as if the "UnitActionsButton" has been clicked renderComponent({ - sidebarOpen: true, activeBlock: mockUnit, filterUnit: mockUnit, filteredBlocks: ['block-v1:TEST+COURSE+SECTION1+prob2'], @@ -194,7 +174,6 @@ describe('AspectsSidebar', () => { it('navigate to component and back in filtered unit view', () => { renderComponent({ - sidebarOpen: true, activeBlock: mockUnit, filterUnit: mockUnit, filteredBlocks: ['block-v1:TEST+COURSE+SECTION1+prob2'], @@ -220,7 +199,7 @@ describe('AspectsSidebar', () => { it('posts a callback with the activatedBlock in Unit Page View', () => { const callback = jest.fn(); - renderComponent({ sidebarOpen: true }, { + renderComponent(undefined, { title: 'Test Unit', blockType: BlockTypes.vertical, contentLists: mockContentLists.slice(0, 1), diff --git a/src/components/AspectsSidebar/index.tsx b/src/components/AspectsSidebar/index.tsx index 2a69b561..660259e7 100644 --- a/src/components/AspectsSidebar/index.tsx +++ b/src/components/AspectsSidebar/index.tsx @@ -1,10 +1,10 @@ import { useIntl } from '@edx/frontend-platform/i18n'; import * as React from 'react'; import { - Alert, Icon, IconButton, IconButtonWithTooltip, Stack, Sticky, + Alert, Icon, IconButton, Stack, Sticky, } from '@openedx/paragon'; import { - ArrowBack, AutoGraph, Close, Warning, + ArrowBack, Warning, } from '@openedx/paragon/icons'; import { BlockTypes, ICON_MAP } from '../../constants'; import { useAspectsSidebarContext } from '../../hooks'; @@ -36,7 +36,7 @@ export function AspectsSidebar({ }: AspectsSidebarProps) { const intl = useIntl(); const { - sidebarOpen, setSidebarOpen, setFilteredBlocks, activeBlock, setActiveBlock, + setFilteredBlocks, activeBlock, setActiveBlock, filterUnit, setFilterUnit, filteredBlocks, } = useAspectsSidebarContext(); @@ -52,10 +52,6 @@ export function AspectsSidebar({ // eslint-disable-next-line react-hooks/exhaustive-deps }, []); - if (!sidebarOpen) { - return null; - } - const hideDashboard: boolean = ( (!!activeBlock && (activeBlock.type === 'vertical')) || (!activeBlock && (blockType === 'vertical')) @@ -92,32 +88,7 @@ export function AspectsSidebar({
- -
- {intl.formatMessage(messages.analyticsLabel)} - -
- { - setSidebarOpen(false); - }} - size="sm" - /> -
-

+

{(activeBlock) && ( { - setSidebarOpen(!sidebarOpen); - setFilterUnit(null); - setActiveBlock(null); - setFilteredBlocks([]); - }} - > - {intl.formatMessage(messages.analyticsLabel)} - - ); -} diff --git a/src/components/CourseOutlineSidebar.test.tsx b/src/components/CourseOutlineSidebar.test.tsx index e27a221a..391dcae0 100644 --- a/src/components/CourseOutlineSidebar.test.tsx +++ b/src/components/CourseOutlineSidebar.test.tsx @@ -1,7 +1,13 @@ import * as React from 'react'; -import { render } from '@testing-library/react'; +import { render, screen } from '@testing-library/react'; import { useIntl } from '@edx/frontend-platform/i18n'; -import { CourseOutlineSidebar } from './CourseOutlineSidebar'; + +import { + useOutlineSidebarPagesContext, + // @ts-ignore +} from 'CourseAuthoring/course-outline/outline-sidebar/OutlineSidebarPagesContext'; + +import { CourseOutlineAspectsPage, CourseOutlineSidebarWrapper } from './CourseOutlineSidebar'; import { useCourseBlocks, useAspectsSidebarContext } from '../hooks'; import { AspectsSidebar } from './AspectsSidebar'; import { BlockTypes } from '../constants'; @@ -24,6 +30,22 @@ jest.mock('../hooks', () => ({ useAspectsSidebarContext: jest.fn(), })); +jest.mock( + 'CourseAuthoring/course-outline/outline-sidebar/OutlineSidebarPagesContext', + () => { + const mockOutlineSidebarPagesContext = React.createContext({}); + const useOutlineSidebarPagesContextMocked = ( + () => React.useContext(mockOutlineSidebarPagesContext) as Record + ); + + return { + OutlineSidebarPagesContext: mockOutlineSidebarPagesContext, + useOutlineSidebarPagesContext: useOutlineSidebarPagesContextMocked, + }; + }, + { virtual: true }, +); + // Test Data const mockCourseId = 'course-v1:TestX+TST101+2025'; const mockCourseName = 'Test Course 101'; @@ -103,7 +125,7 @@ const mockVideos: Block[] = [ ]; // Test Suite -describe('CourseOutlineSidebar', () => { +describe('CourseOutlineAspectsPage', () => { // Mock implementations setup const mockFormatMessage = jest.fn((message) => message.defaultMessage || message.id); const mockUseIntl = useIntl as jest.Mock; @@ -122,13 +144,13 @@ describe('CourseOutlineSidebar', () => { MockAspectsSidebar.mockClear(); // Clear calls specifically for the component mock }); - const renderComponent = (props: Partial> = {}) => { + const renderComponent = (props: Partial> = {}) => { const defaultProps = { courseId: mockCourseId, courseName: mockCourseName, sections: mockSections, }; - return render(); + return render(); }; // --- Test Cases --- @@ -310,3 +332,30 @@ describe('CourseOutlineSidebar', () => { expect(mockFormatMessage).not.toHaveBeenCalledWith(messages.gradedSubsectionAnalytics); }); }); + +function MockComponent() { + const sidebarPages = useOutlineSidebarPagesContext(); + + // Return a div with the title of the analytics page, reading from the context + return ( +
+ {sidebarPages.analytics.title.defaultMessage} +
+ ); +} + +describe('CourseOutlineSidebarWrapper', () => { + const renderComponent = () => render(} + pluginProps={{ + courseId: mockCourseId, + courseName: mockCourseName, + sections: mockSections, + }} + />); + + it('adds analytics page to the sidebar', () => { + renderComponent(); + expect(screen.getByText('Analytics')).toBeInTheDocument(); + }); +}); diff --git a/src/components/CourseOutlineSidebar.tsx b/src/components/CourseOutlineSidebar.tsx index a3efc8d8..07da9269 100644 --- a/src/components/CourseOutlineSidebar.tsx +++ b/src/components/CourseOutlineSidebar.tsx @@ -1,12 +1,20 @@ -import * as React from 'react'; +import React, { useMemo } from 'react'; import { useIntl } from '@edx/frontend-platform/i18n'; +import { AutoGraph } from '@openedx/paragon/icons'; + +import { + OutlineSidebarPagesContext, + useOutlineSidebarPagesContext, +// @ts-ignore +} from 'CourseAuthoring/course-outline/outline-sidebar/OutlineSidebarPagesContext'; + import { useCourseBlocks, useAspectsSidebarContext } from '../hooks'; import { BlockTypes } from '../constants'; import { AspectsSidebar } from './AspectsSidebar'; import messages from '../messages'; import { Section, Block, castToBlock } from '../types'; -interface Props { +interface CourseOutlineAspectsPageProps { courseId: string; courseName: string; sections: Section[]; @@ -22,7 +30,7 @@ function* getGradedSubsections(sections: Section[]) { } } -export function CourseOutlineSidebar({ courseId, courseName, sections }: Props) { +export function CourseOutlineAspectsPage({ courseId, courseName, sections }: CourseOutlineAspectsPageProps) { const intl = useIntl(); const { filteredBlocks } = useAspectsSidebarContext(); const { data } = useCourseBlocks(courseId); @@ -62,3 +70,26 @@ export function CourseOutlineSidebar({ courseId, courseName, sections }: Props) /> ); } + +export function CourseOutlineSidebarWrapper( + { component, pluginProps }: { component: React.ReactNode, pluginProps: CourseOutlineAspectsPageProps }, +) { + const sidebarPages = useOutlineSidebarPagesContext(); + + const AnalyticsPage = React.useCallback(() => , [pluginProps]); + + const overridedPages = useMemo(() => ({ + ...sidebarPages, + analytics: { + component: AnalyticsPage, + icon: AutoGraph, + title: messages.analyticsLabel, + }, + }), [sidebarPages, AnalyticsPage]); + + return ( + + {component} + + ); +} diff --git a/src/components/SidebarToggleWrapper.tsx b/src/components/SidebarToggleWrapper.tsx deleted file mode 100644 index 74e55fda..00000000 --- a/src/components/SidebarToggleWrapper.tsx +++ /dev/null @@ -1,11 +0,0 @@ -import { ReactNode } from 'react'; -import { useAspectsSidebarContext } from '../hooks'; - -/** - * This plugin component is meant to wrap the sidebar so that the other/default sidebars - * are hidden when the Aspects sidebar is displayed. - */ -export const SidebarToggleWrapper = ({ component }: { component: ReactNode }) => { - const { sidebarOpen } = useAspectsSidebarContext(); - return !sidebarOpen && component; -}; diff --git a/src/components/SubSectionAnalyticsButton.test.tsx b/src/components/SubSectionAnalyticsButton.test.tsx index 412cf47d..43c2508e 100644 --- a/src/components/SubSectionAnalyticsButton.test.tsx +++ b/src/components/SubSectionAnalyticsButton.test.tsx @@ -47,7 +47,6 @@ const mockSubsections: SubSection[] = [ const mockUseAspectsSidebarContext = useAspectsSidebarContext as jest.Mock; describe('SubSectionAnalyticsButton', () => { - const mockSetSidebarOpen = jest.fn(); const mockSetActiveBlock = jest.fn(); const mockSetFilterUnit = jest.fn(); @@ -55,7 +54,6 @@ describe('SubSectionAnalyticsButton', () => { activeBlock: null, sidebarOpen: false, setActiveBlock: mockSetActiveBlock, - setSidebarOpen: mockSetSidebarOpen, setFilterUnit: mockSetFilterUnit, }; @@ -79,7 +77,6 @@ describe('SubSectionAnalyticsButton', () => { const button = screen.getByRole('button', { name: /analytics/i }); await user.click(button); - expect(mockSetSidebarOpen).toHaveBeenCalledWith(true); expect(mockSetActiveBlock).toHaveBeenCalledWith(expect.objectContaining({ id: mockSubsection.id, })); @@ -92,14 +89,12 @@ describe('SubSectionAnalyticsButton', () => { mockUseAspectsSidebarContext.mockReturnValue({ ...defaultContextValue, activeBlock: { id: mockSubsection.id } as any, - sidebarOpen: true, }); render(); const button = screen.getByRole('button', { name: /analytics/i }); await user.click(button); - expect(mockSetSidebarOpen).toHaveBeenCalledWith(true); expect(mockSetActiveBlock).toHaveBeenCalledWith(null); expect(mockSetFilterUnit).not.toHaveBeenCalled(); }); diff --git a/src/components/SubSectionAnalyticsButton.tsx b/src/components/SubSectionAnalyticsButton.tsx index 14266236..9b00f1e5 100644 --- a/src/components/SubSectionAnalyticsButton.tsx +++ b/src/components/SubSectionAnalyticsButton.tsx @@ -1,4 +1,3 @@ -import * as React from 'react'; import { IconButton } from '@openedx/paragon'; import { AutoGraph } from '@openedx/paragon/icons'; import { useAspectsSidebarContext } from '../hooks'; @@ -6,8 +5,7 @@ import { Block, SubSection, castToBlock } from '../types'; export function SubSectionAnalyticsButton({ subsection }: { subsection: SubSection }) { const { - activeBlock, sidebarOpen, setActiveBlock, setSidebarOpen, - setFilterUnit, + activeBlock, setActiveBlock, setFilterUnit, } = useAspectsSidebarContext(); if (!subsection.graded) { return null; @@ -16,9 +14,8 @@ export function SubSectionAnalyticsButton({ subsection }: { subsection: SubSecti { - setSidebarOpen(true); if (activeBlock?.id === subsection.id) { setActiveBlock(null); } else { diff --git a/src/components/UnitActionsButton.tsx b/src/components/UnitActionsButton.tsx index 725df8a8..a18a0a16 100644 --- a/src/components/UnitActionsButton.tsx +++ b/src/components/UnitActionsButton.tsx @@ -1,7 +1,6 @@ import { useIntl } from '@edx/frontend-platform/i18n'; import { Icon, IconButton } from '@openedx/paragon'; import { AutoGraph } from '@openedx/paragon/icons'; -import * as React from 'react'; import messages from '../messages'; import { useAspectsSidebarContext, useChildBlockCounts } from '../hooks'; import { Block, Unit, castToBlock } from '../types'; @@ -11,8 +10,6 @@ export function UnitActionsButton({ }: { unit: Unit }) { const intl = useIntl(); const { - sidebarOpen, - setSidebarOpen, setFilteredBlocks, setActiveBlock, filterUnit, @@ -28,7 +25,7 @@ export function UnitActionsButton({ return ( { - setSidebarOpen(true); if (filterUnit?.id === unit.id) { setActiveBlock(null); setFilteredBlocks([]); diff --git a/src/hooks.ts b/src/hooks.ts index e9809201..f91f5447 100644 --- a/src/hooks.ts +++ b/src/hooks.ts @@ -100,14 +100,12 @@ export const useChildBlockCounts = (usageKey: string) : { data: BlockResponse | }; type SidebarState = { - sidebarOpen: boolean, activeBlock: Block | null, filteredBlocks: string[], filterUnit: Block | null, }; const sidebarState = hookstate({ - sidebarOpen: false, activeBlock: null, filteredBlocks: [], filterUnit: null, @@ -116,7 +114,6 @@ const sidebarState = hookstate({ interface SidebarContextFunctions { setActiveBlock: (block: Block | null) => void, setFilteredBlocks: (blocks: string[]) => void, - setSidebarOpen: (value: boolean) => void, setFilterUnit: (block: Block | null) => void, } @@ -126,13 +123,9 @@ export const useAspectsSidebarContext = (): SidebarContext => { const state = useHookstate(sidebarState); return { - sidebarOpen: state.sidebarOpen.get(), activeBlock: state.activeBlock.get(), filteredBlocks: state.filteredBlocks.get() as string[], filterUnit: state.filterUnit.get(), - setSidebarOpen: (value: boolean) => { - state.sidebarOpen.set(value); - }, setActiveBlock: (value: Block | null) => { state.activeBlock.set(JSON.parse(JSON.stringify(value))); }, diff --git a/src/index.ts b/src/index.ts index d33ea899..e4bb37dc 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,7 +1,5 @@ -export { CourseHeaderButton } from './components/CourseHeaderButton'; -export { SidebarToggleWrapper } from './components/SidebarToggleWrapper'; export { UnitActionsButton } from './components/UnitActionsButton'; export { pluginSlots } from './plugin-slots'; -export { CourseOutlineSidebar } from './components/CourseOutlineSidebar'; +export { CourseOutlineSidebarWrapper } from './components/CourseOutlineSidebar'; export { UnitPageSidebar } from './components/UnitPageSidebar'; export { SubSectionAnalyticsButton } from './components/SubSectionAnalyticsButton'; diff --git a/src/messages.ts b/src/messages.ts index 7eccc0c0..53d1632c 100644 --- a/src/messages.ts +++ b/src/messages.ts @@ -6,11 +6,6 @@ const messages = defineMessages({ defaultMessage: 'Analytics', description: 'Label for the analytics buttons on the course outline and unit header etc.', }, - closeButtonLabel: { - id: 'aspects.button.close.alt', - defaultMessage: 'Close', - description: 'Label for the close icon button in the sidebar.', - }, backButtonLabel: { id: 'aspects.button.back.alt', defaultMessage: 'Back', diff --git a/src/plugin-slots.ts b/src/plugin-slots.ts index 4326e8ec..69785bc1 100644 --- a/src/plugin-slots.ts +++ b/src/plugin-slots.ts @@ -1,8 +1,6 @@ import { DIRECT_PLUGIN, PLUGIN_OPERATIONS } from '@openedx/frontend-plugin-framework'; -import { CourseHeaderButton } from './components/CourseHeaderButton'; -import { SidebarToggleWrapper } from './components/SidebarToggleWrapper'; import { UnitActionsButton } from './components/UnitActionsButton'; -import { CourseOutlineSidebar } from './components/CourseOutlineSidebar'; +import { CourseOutlineSidebarWrapper } from './components/CourseOutlineSidebar'; import { UnitPageSidebar } from './components/UnitPageSidebar'; export const pluginSlots = { @@ -10,19 +8,12 @@ export const pluginSlots = { keepDefault: true, plugins: [ { - op: PLUGIN_OPERATIONS.Insert, + op: PLUGIN_OPERATIONS.Wrap, widget: { - id: 'outline-sidebar', - priority: 1, - type: DIRECT_PLUGIN, - RenderWidget: CourseOutlineSidebar, + id: 'default_contents', + RenderWidget: CourseOutlineSidebarWrapper, }, }, - { - op: PLUGIN_OPERATIONS.Wrap, - widgetId: 'default_contents', - wrapper: SidebarToggleWrapper, - }, ], }, course_authoring_unit_sidebar_slot: { @@ -37,39 +28,6 @@ export const pluginSlots = { RenderWidget: UnitPageSidebar, }, }, - { - op: PLUGIN_OPERATIONS.Wrap, - widgetId: 'default_contents', - wrapper: SidebarToggleWrapper, - }, - ], - }, - course_unit_header_actions_slot: { - keepDefault: true, - plugins: [ - { - op: PLUGIN_OPERATIONS.Insert, - widget: { - id: 'unit-header-aspects-button', - priority: 60, - type: DIRECT_PLUGIN, - RenderWidget: CourseHeaderButton, - }, - }, - ], - }, - course_outline_header_actions_slot: { - keepDefault: true, - plugins: [ - { - op: PLUGIN_OPERATIONS.Insert, - widget: { - id: 'outline-header-aspects-button', - priority: 60, - type: DIRECT_PLUGIN, - RenderWidget: CourseHeaderButton, - }, - }, ], }, course_outline_unit_card_extra_actions_slot: { From 25f6686fca45beca8a56bc68b09023b09467fef0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=B4mulo=20Penido?= Date: Wed, 28 Jan 2026 11:02:27 -0300 Subject: [PATCH 02/12] fix: add React import again --- src/components/UnitActionsButton.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/components/UnitActionsButton.tsx b/src/components/UnitActionsButton.tsx index a18a0a16..af63c6d0 100644 --- a/src/components/UnitActionsButton.tsx +++ b/src/components/UnitActionsButton.tsx @@ -1,6 +1,8 @@ import { useIntl } from '@edx/frontend-platform/i18n'; import { Icon, IconButton } from '@openedx/paragon'; import { AutoGraph } from '@openedx/paragon/icons'; +import * as React from 'react'; + import messages from '../messages'; import { useAspectsSidebarContext, useChildBlockCounts } from '../hooks'; import { Block, Unit, castToBlock } from '../types'; From e988d9a9067e9bedfc8b0c2d36ca0f5c5d49cf16 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=B4mulo=20Penido?= Date: Thu, 29 Jan 2026 18:41:46 -0300 Subject: [PATCH 03/12] fix: CourseAuthoring alias working now --- src/components/CourseOutlineSidebar.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/src/components/CourseOutlineSidebar.tsx b/src/components/CourseOutlineSidebar.tsx index 07da9269..980149d8 100644 --- a/src/components/CourseOutlineSidebar.tsx +++ b/src/components/CourseOutlineSidebar.tsx @@ -5,7 +5,6 @@ import { AutoGraph } from '@openedx/paragon/icons'; import { OutlineSidebarPagesContext, useOutlineSidebarPagesContext, -// @ts-ignore } from 'CourseAuthoring/course-outline/outline-sidebar/OutlineSidebarPagesContext'; import { useCourseBlocks, useAspectsSidebarContext } from '../hooks'; From 708611c48671ec3fa7cadc04a688c827038205ac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=B4mulo=20Penido?= Date: Fri, 13 Feb 2026 18:01:23 -0300 Subject: [PATCH 04/12] fix: remove sidebar rounded div --- src/components/AspectsSidebar/index.tsx | 86 ++++++++++++------------- 1 file changed, 41 insertions(+), 45 deletions(-) diff --git a/src/components/AspectsSidebar/index.tsx b/src/components/AspectsSidebar/index.tsx index 660259e7..aa7b0bea 100644 --- a/src/components/AspectsSidebar/index.tsx +++ b/src/components/AspectsSidebar/index.tsx @@ -85,54 +85,50 @@ export function AspectsSidebar({ return (
- -
- -

- {(activeBlock) && ( - goBack()} - size="sm" - /> - )} - +

+ {(activeBlock) && ( + goBack()} size="sm" - className="d-inline-block mr-2 text-gray" - aria-hidden /> - {topTitle} -

- - { !hideDashboard && ( - - )} - {((activeBlockType === BlockTypes.course) || (activeBlockType === BlockTypes.vertical)) - && contentLists.map(({ title: listTitle, blocks }) => ( - filteredBlocks.includes(block.id)) : blocks} - activateDashboard={activateDashboard} - /> - ))} - - {(hideDashboard && !contentListSize) - && ( - - { - blockType === 'course' - ? intl.formatMessage(messages.noAnalyticsForCourse) - : intl.formatMessage(messages.noAnalyticsForUnit) - } - )} -

-
+ + {topTitle} +

+
+ { !hideDashboard && ( + + )} + {((activeBlockType === BlockTypes.course) || (activeBlockType === BlockTypes.vertical)) + && contentLists.map(({ title: listTitle, blocks }) => ( + filteredBlocks.includes(block.id)) : blocks} + activateDashboard={activateDashboard} + /> + ))} + + {(hideDashboard && !contentListSize) + && ( + + { + blockType === 'course' + ? intl.formatMessage(messages.noAnalyticsForCourse) + : intl.formatMessage(messages.noAnalyticsForUnit) + } + + )}
); } From d6ba22b952df45b24c0b3a4b475b17a305cdd132 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=B4mulo=20Penido?= Date: Fri, 13 Feb 2026 18:01:50 -0300 Subject: [PATCH 05/12] feat: update to new unit outline sidebar --- README.rst | 58 ++--------------- src/components/AspectsSidebar/index.tsx | 84 ++++++++++++------------- src/components/CourseOutlineSidebar.tsx | 16 ++++- src/components/UnitPageSidebar.tsx | 73 +++++++++++++++++++-- src/index.ts | 4 +- src/plugin-slots.ts | 18 ++---- src/types.ts | 33 +++++++--- 7 files changed, 159 insertions(+), 127 deletions(-) diff --git a/README.rst b/README.rst index 78c4f684..dcbfcd50 100644 --- a/README.rst +++ b/README.rst @@ -66,14 +66,12 @@ Development Setup .. code-block:: jsx +<<<<<<< HEAD import { PLUGIN_OPERATIONS, DIRECT_PLUGIN } from "@openedx/frontend-plugin-framework"; import { - SidebarToggleWrapper, - CourseHeaderButton, UnitActionsButton, - AspectsSidebarProvider, - CourseOutlineSidebar, - UnitPageSidebar, + CourseOutlineSidebarWrapper, + UnitOutlineSidebarWrapper, SubSectionAnalyticsButton, } from "@openedx/frontend-plugin-aspects"; @@ -83,66 +81,20 @@ Development Setup "org.openedx.frontend.authoring.course_outline_sidebar.v1": { keepDefault: true, plugins: [ - { - op: PLUGIN_OPERATIONS.Insert, - widget: { - id: "outline-sidebar", - priority: 1, - type: DIRECT_PLUGIN, - RenderWidget: CourseOutlineSidebar, - }, - }, { op: PLUGIN_OPERATIONS.Wrap, widgetId: "default_contents", - wrapper: SidebarToggleWrapper, + wrapper: CourseOutlineSidebarWrapper, }, ], }, "org.openedx.frontend.authoring.course_unit_sidebar.v2": { keepDefault: true, plugins: [ - { - op: PLUGIN_OPERATIONS.Insert, - widget: { - id: "course-unit-sidebar", - priority: 1, - type: DIRECT_PLUGIN, - RenderWidget: UnitPageSidebar, - }, - }, { op: PLUGIN_OPERATIONS.Wrap, widgetId: "default_contents", - wrapper: SidebarToggleWrapper, - }, - ], - }, - "org.openedx.frontend.authoring.course_outline_header_actions.v1": { - keepDefault: true, - plugins: [ - { - op: PLUGIN_OPERATIONS.Insert, - widget: { - id: "outline-analytics", - type: DIRECT_PLUGIN, - priority: 51, - RenderWidget: CourseHeaderButton, - }, - }, - ], - }, - "org.openedx.frontend.authoring.course_unit_header_actions.v1": { - keepDefault: true, - plugins: [ - { - op: PLUGIN_OPERATIONS.Insert, - widget: { - id: "unit-analytics", - type: DIRECT_PLUGIN, - priority: 51, - RenderWidget: CourseHeaderButton, - }, + wrapper: UnitOutlineSidebarWrapper, }, ], }, diff --git a/src/components/AspectsSidebar/index.tsx b/src/components/AspectsSidebar/index.tsx index aa7b0bea..f9097e01 100644 --- a/src/components/AspectsSidebar/index.tsx +++ b/src/components/AspectsSidebar/index.tsx @@ -1,7 +1,7 @@ import { useIntl } from '@edx/frontend-platform/i18n'; import * as React from 'react'; import { - Alert, Icon, IconButton, Stack, Sticky, + Alert, Icon, IconButton, Stack, } from '@openedx/paragon'; import { ArrowBack, Warning, @@ -85,50 +85,50 @@ export function AspectsSidebar({ return (
- -

- {(activeBlock) && ( - goBack()} - size="sm" - /> - )} - +

+ {(activeBlock) && ( + goBack()} size="sm" - className="d-inline-block mr-2 text-gray" - aria-hidden /> - {topTitle} -

- - { !hideDashboard && ( - - )} - {((activeBlockType === BlockTypes.course) || (activeBlockType === BlockTypes.vertical)) - && contentLists.map(({ title: listTitle, blocks }) => ( - filteredBlocks.includes(block.id)) : blocks} - activateDashboard={activateDashboard} - /> - ))} - - {(hideDashboard && !contentListSize) - && ( - - { - blockType === 'course' - ? intl.formatMessage(messages.noAnalyticsForCourse) - : intl.formatMessage(messages.noAnalyticsForUnit) - } - )} + + {topTitle} +

+
+ { !hideDashboard && ( + + )} + {((activeBlockType === BlockTypes.course) || (activeBlockType === BlockTypes.vertical)) + && contentLists.map(({ title: listTitle, blocks }) => ( + filteredBlocks.includes(block.id)) : blocks} + activateDashboard={activateDashboard} + /> + ))} + + {(hideDashboard && !contentListSize) + && ( + + { + blockType === 'course' + ? intl.formatMessage(messages.noAnalyticsForCourse) + : intl.formatMessage(messages.noAnalyticsForUnit) + } + + )}
); } diff --git a/src/components/CourseOutlineSidebar.tsx b/src/components/CourseOutlineSidebar.tsx index 980149d8..8b425636 100644 --- a/src/components/CourseOutlineSidebar.tsx +++ b/src/components/CourseOutlineSidebar.tsx @@ -1,7 +1,10 @@ -import React, { useMemo } from 'react'; +import React, { useEffect, useMemo } from 'react'; import { useIntl } from '@edx/frontend-platform/i18n'; import { AutoGraph } from '@openedx/paragon/icons'; +import { + useOutlineSidebarContext, +} from 'CourseAuthoring/course-outline/outline-sidebar/OutlineSidebarContext'; import { OutlineSidebarPagesContext, useOutlineSidebarPagesContext, @@ -31,17 +34,24 @@ function* getGradedSubsections(sections: Section[]) { export function CourseOutlineAspectsPage({ courseId, courseName, sections }: CourseOutlineAspectsPageProps) { const intl = useIntl(); - const { filteredBlocks } = useAspectsSidebarContext(); + const { filteredBlocks, setFilteredBlocks } = useAspectsSidebarContext(); const { data } = useCourseBlocks(courseId); + const { currentItemData } = useOutlineSidebarContext(); const gradedSubsections = sections ? Array.from(getGradedSubsections(sections)) : null; const problems = data?.problems; const videos = data?.videos; + useEffect(() => { + if (currentItemData?.id) { + setFilteredBlocks([currentItemData.id]); + } + }, [currentItemData]); + const contentLists: { title: string, blocks: Block[] }[] = []; // graded subsections are shown only when unit-filtering is off - if (!filteredBlocks?.length && gradedSubsections?.length) { + if (!filteredBlocks.length && gradedSubsections?.length) { contentLists.push({ title: intl.formatMessage(messages.gradedSubsectionAnalytics), blocks: castToBlock(gradedSubsections) as Block[], diff --git a/src/components/UnitPageSidebar.tsx b/src/components/UnitPageSidebar.tsx index 70d9a976..3830c90c 100644 --- a/src/components/UnitPageSidebar.tsx +++ b/src/components/UnitPageSidebar.tsx @@ -1,21 +1,59 @@ -import * as React from 'react'; -// @ts-ignore +import React, { useCallback, useEffect,useMemo } from 'react'; +import { AutoGraph } from '@openedx/paragon/icons'; + +import { + useUnitSidebarContext, +} from 'CourseAuthoring/course-unit/unit-sidebar/UnitSidebarContext'; +import { + UnitSidebarPagesContext, + useUnitSidebarPagesContext, +} from 'CourseAuthoring/course-unit/unit-sidebar/UnitSidebarPagesContext'; import { useIframe } from 'CourseAuthoring/generic/hooks/context/hooks'; + import { BlockTypes } from '../constants'; -import { AspectsSidebar, ContentList } from './AspectsSidebar'; +import messages from '../messages'; +import { useAspectsSidebarContext } from '../hooks'; import { castToBlock, XBlock, Block } from '../types'; +import { AspectsSidebar, ContentList } from './AspectsSidebar'; -interface Props { +interface UnitOutlineAspectsPageProps { blockId: string; unitTitle: string; xBlocks: XBlock[]; } -export function UnitPageSidebar({ +export function UnitOutlineAspectsPage({ blockId, unitTitle, xBlocks, -}: Props) { +}: UnitOutlineAspectsPageProps) { const { sendMessageToIframe } = useIframe(); const contentLists: ContentList[] = []; + + const { selectedComponentId } = useUnitSidebarContext(); + const { + activeBlock, + setActiveBlock, + setFilteredBlocks, + } = useAspectsSidebarContext(); + + + useEffect(() => { + const xBlock = xBlocks.find(xblock => xblock.id === selectedComponentId); + const block = xBlock && castToBlock(xBlock); + if (block && ['problem', 'video'].includes(block.type)) { + setActiveBlock(block); + setFilteredBlocks([selectedComponentId]); + } else { + setActiveBlock(null); + setFilteredBlocks([]); + } + }, [selectedComponentId]); + + useEffect(() => { + if (!activeBlock) { + sendMessageToIframe('clearSelection'); + } + }, [activeBlock]); + if (xBlocks && xBlocks.length) { const blocks = castToBlock(xBlocks) as Block[]; contentLists.push({ @@ -34,3 +72,26 @@ export function UnitPageSidebar({ /> ); } + +export function UnitOutlineSidebarWrapper( + { component, pluginProps }: { component: React.ReactNode, pluginProps: UnitOutlineAspectsPageProps }, +) { + const sidebarPages = useUnitSidebarPagesContext(); + + const AnalyticsPage = useCallback(() => , [pluginProps]); + + const overridedPages = useMemo(() => ({ + ...sidebarPages, + analytics: { + component: AnalyticsPage, + icon: AutoGraph, + title: messages.analyticsLabel, + }, + }), [sidebarPages, AnalyticsPage]); + + return ( + + {component} + + ); +} diff --git a/src/index.ts b/src/index.ts index e4bb37dc..1981c8c3 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,5 @@ export { UnitActionsButton } from './components/UnitActionsButton'; -export { pluginSlots } from './plugin-slots'; export { CourseOutlineSidebarWrapper } from './components/CourseOutlineSidebar'; -export { UnitPageSidebar } from './components/UnitPageSidebar'; +export { UnitOutlineSidebarWrapper } from './components/UnitPageSidebar'; export { SubSectionAnalyticsButton } from './components/SubSectionAnalyticsButton'; +export { pluginSlots } from './plugin-slots'; diff --git a/src/plugin-slots.ts b/src/plugin-slots.ts index 69785bc1..f117746c 100644 --- a/src/plugin-slots.ts +++ b/src/plugin-slots.ts @@ -1,7 +1,7 @@ import { DIRECT_PLUGIN, PLUGIN_OPERATIONS } from '@openedx/frontend-plugin-framework'; import { UnitActionsButton } from './components/UnitActionsButton'; import { CourseOutlineSidebarWrapper } from './components/CourseOutlineSidebar'; -import { UnitPageSidebar } from './components/UnitPageSidebar'; +import { UnitOutlineSidebarWrapper } from './components/UnitPageSidebar'; export const pluginSlots = { course_authoring_outline_sidebar_slot: { @@ -9,10 +9,8 @@ export const pluginSlots = { plugins: [ { op: PLUGIN_OPERATIONS.Wrap, - widget: { - id: 'default_contents', - RenderWidget: CourseOutlineSidebarWrapper, - }, + widgetId: 'default_contents', + wrapper: CourseOutlineSidebarWrapper, }, ], }, @@ -20,13 +18,9 @@ export const pluginSlots = { keepDefault: true, plugins: [ { - op: PLUGIN_OPERATIONS.Insert, - widget: { - id: 'course-unit-sidebar', - priority: 1, - type: DIRECT_PLUGIN, - RenderWidget: UnitPageSidebar, - }, + op: PLUGIN_OPERATIONS.Wrap, + widgetId: 'default_contents', + wrapper: UnitOutlineSidebarWrapper, }, ], }, diff --git a/src/types.ts b/src/types.ts index 3d972ff1..c0344a3e 100644 --- a/src/types.ts +++ b/src/types.ts @@ -51,6 +51,15 @@ export type BlockResponse = { root: UsageId; }; +function xblockToBlock(xblock: XBlock): Block { + return { + id: xblock.id, + type: xblock.blockType, + displayName: xblock.name, + graded: false, + }; +} + function categoryItemToBlock(item: SubSection | Unit): Block { const block: Block = { id: item.id, @@ -63,16 +72,25 @@ function categoryItemToBlock(item: SubSection | Unit): Block { } return block; } + +function isCategoryItem(item: SubSection | Unit | XBlock): item is SubSection | Unit { + return 'category' in item; +} + /** * Converts multiple types of context blocks into a consistent type * that can be used across the components. * * @param items - Various kinds of blocks received from API and Props - * @return Block[] */ -export function castToBlock(items: SubSection | SubSection[] | Unit | XBlock[]): Block[] | Block { +export function castToBlock(items: SubSection | Unit | XBlock): Block; +export function castToBlock(items: SubSection[] | XBlock[]): Block[]; +export function castToBlock(items: SubSection | SubSection[] | Unit | XBlock | XBlock[]): Block[] | Block { if (!Array.isArray(items)) { - return categoryItemToBlock(items); + if (isCategoryItem(items)) { + return categoryItemToBlock(items); + } + return xblockToBlock(items); } const blocks: Block[] = []; @@ -80,12 +98,9 @@ export function castToBlock(items: SubSection | SubSection[] | Unit | XBlock[]): if ('category' in item) { blocks.push(categoryItemToBlock(item)); } else if ('blockType' in item) { // XBlock - blocks.push({ - id: item.id, - type: item.blockType, - displayName: item.name, - graded: false, - }); + blocks.push( + xblockToBlock(item), + ); } } return blocks; From c9697c63fa9de5535ec5ba5ab6fab4919b1552e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=B4mulo=20Penido?= Date: Wed, 4 Mar 2026 20:14:46 -0300 Subject: [PATCH 06/12] feat: add in context analytics --- .../AspectsSidebar/CourseContentList.tsx | 8 +- src/components/AspectsSidebar/Dashboard.tsx | 2 +- src/components/AspectsSidebar/index.test.tsx | 2 +- src/components/AspectsSidebar/index.tsx | 93 +++++++++---------- src/components/CourseOutlineSidebar.test.tsx | 2 +- src/components/CourseOutlineSidebar.tsx | 86 +++++++++++------ src/components/SubSectionAnalyticsButton.tsx | 31 ++++--- src/components/UnitActionsButton.tsx | 34 ++----- src/components/UnitPageSidebar.test.tsx | 2 +- src/components/UnitPageSidebar.tsx | 10 +- src/constants.ts | 9 +- src/hooks.ts | 9 +- 12 files changed, 150 insertions(+), 138 deletions(-) diff --git a/src/components/AspectsSidebar/CourseContentList.tsx b/src/components/AspectsSidebar/CourseContentList.tsx index 25818d19..f77230d8 100644 --- a/src/components/AspectsSidebar/CourseContentList.tsx +++ b/src/components/AspectsSidebar/CourseContentList.tsx @@ -1,4 +1,4 @@ -import * as React from 'react'; +import React from 'react'; import { useIntl } from '@edx/frontend-platform/i18n'; import { Button, Icon } from '@openedx/paragon'; import { ArrowDropDown, ArrowDropUp, ChevronRight } from '@openedx/paragon/icons'; @@ -7,13 +7,12 @@ import { ICON_MAP } from '../../constants'; import { Block } from '../../types'; interface CourseContentListProps { - title: string, blocks: Block[], activateDashboard: (block: Block) => void, } export function CourseContentList({ - title, blocks, activateDashboard, + blocks, activateDashboard, }: CourseContentListProps) { const intl = useIntl(); // using undefined is useful for slicing the list @@ -24,8 +23,7 @@ export function CourseContentList({ } return ( -
- {!!title &&

{title}

} +
{blocks?.slice(0, showCount).map(block => ( } + {title} +
+ ), + }), + { virtual: true }, +); + const mockDashboard = jest.fn(); jest.mock('./Dashboard', () => ({ Dashboard: () => mockDashboard, diff --git a/src/components/AspectsSidebar/index.tsx b/src/components/AspectsSidebar/index.tsx index 42382d1a..e2cd7809 100644 --- a/src/components/AspectsSidebar/index.tsx +++ b/src/components/AspectsSidebar/index.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { FormattedMessage } from '@edx/frontend-platform/i18n'; +import { useIntl } from '@edx/frontend-platform/i18n'; import { Alert, } from '@openedx/paragon'; @@ -38,6 +38,8 @@ export function AspectsSidebar({ blockActivatedCallback, showNoAnalyticsMessage, }: AspectsSidebarProps) { + const intl = useIntl(); + const { setFilteredBlocks, activeBlock, setActiveBlock, filterUnit, setFilterUnit, filteredBlocks, @@ -89,6 +91,7 @@ export function AspectsSidebar({ }; const messageNoAnalyticsFor = { + undefined: messages.noAnalyticsForCourse, course: messages.noAnalyticsForCourse, chapter: messages.noAnalyticsForSection, vertical: messages.noAnalyticsForUnit, @@ -96,11 +99,14 @@ export function AspectsSidebar({ return (
- +
+ +
{ !showNoAnalyticsMessage && !hideDashboard && ( @@ -127,7 +133,9 @@ export function AspectsSidebar({ )} {(showNoAnalyticsMessage || (hideDashboard && !contentListSize)) && ( - + {(activeBlockType in messageNoAnalyticsFor) ? ( + intl.formatMessage(messageNoAnalyticsFor[activeBlockType]) + ) : null} )} diff --git a/src/components/CourseOutlineSidebar.test.tsx b/src/components/CourseOutlineSidebar.test.tsx index ebfb03e6..9a397743 100644 --- a/src/components/CourseOutlineSidebar.test.tsx +++ b/src/components/CourseOutlineSidebar.test.tsx @@ -2,18 +2,21 @@ import React from 'react'; import { render, screen } from '@testing-library/react'; import { useIntl } from '@edx/frontend-platform/i18n'; +import { + useOutlineSidebarContext, + // @ts-ignore +} from 'CourseAuthoring/course-outline/outline-sidebar/OutlineSidebarContext'; import { useOutlineSidebarPagesContext, // @ts-ignore } from 'CourseAuthoring/course-outline/outline-sidebar/OutlineSidebarPagesContext'; import { CourseOutlineAspectsPage, CourseOutlineSidebarWrapper } from './CourseOutlineSidebar'; -import { useCourseBlocks, useAspectsSidebarContext } from '../hooks'; +import { useCourseBlocks, useChildBlockCounts, useAspectsSidebarContext } from '../hooks'; import { AspectsSidebar } from './AspectsSidebar'; import { BlockTypes } from '../constants'; import messages from '../messages'; -import { Section, Block } from '../types'; -import '@testing-library/jest-dom'; +import { type Section, type Block, castToBlock } from '../types'; // Mock the AspectsSidebar to check props jest.mock('./AspectsSidebar', () => ({ @@ -27,6 +30,7 @@ jest.mock('@edx/frontend-platform/i18n', () => ({ })); jest.mock('../hooks', () => ({ useCourseBlocks: jest.fn(), + useChildBlockCounts: jest.fn(), useAspectsSidebarContext: jest.fn(), })); @@ -46,6 +50,14 @@ jest.mock( { virtual: true }, ); +jest.mock( + 'CourseAuthoring/course-outline/outline-sidebar/OutlineSidebarContext', + () => ({ + useOutlineSidebarContext: jest.fn(), + }), + { virtual: true }, +); + // Test Data const mockCourseId = 'course-v1:TestX+TST101+2025'; const mockCourseName = 'Test Course 101'; @@ -96,8 +108,14 @@ const mockSections: Section[] = [ displayName: 'Graded Sub 2', graded: true, childInfo: { - category: 'vertical', - children: [], + category: + 'vertical', + children: [{ + id: 'unit-id', + category: 'vertical', + displayName: 'Unit 1', + graded: false, + }], displayName: 'Unit', }, }, @@ -124,14 +142,37 @@ const mockVideos: Block[] = [ }, ]; +const mockProblemsBlockCount = { + problem1: mockProblems[0], + problem2: mockProblems[1], +}; + +const mockVideosBlockCount = { + video1: mockVideos[0], + video2: mockVideos[1], +}; + // Test Suite describe('CourseOutlineAspectsPage', () => { // Mock implementations setup const mockFormatMessage = jest.fn((message) => message.defaultMessage || message.id); const mockUseIntl = useIntl as jest.Mock; const mockUseCourseBlocks = useCourseBlocks as jest.Mock; + const mockUseChildBlockCounts = useChildBlockCounts as jest.Mock; const mockUseAspectsSidebarContext = useAspectsSidebarContext as jest.Mock; const MockAspectsSidebar = AspectsSidebar as jest.Mock; // Get the mock constructor + const mockUseOutlineSidebarContext = useOutlineSidebarContext as jest.Mock; + + const defaultUseAspectsSidebarContext = { + filteredBlocks: undefined, + setActiveBlock: jest.fn(), + setFilterUnit: jest.fn(), + setFilteredBlocks: jest.fn(), + }; + + const defaultUseOutlineSidebarContext = { + currenntItemData: undefined, + }; beforeEach(() => { // Reset mocks before each test @@ -140,8 +181,10 @@ describe('CourseOutlineAspectsPage', () => { // Default mock implementations mockUseIntl.mockReturnValue({ formatMessage: mockFormatMessage }); mockUseCourseBlocks.mockReturnValue({ data: null, isLoading: true }); // Default to loading state - mockUseAspectsSidebarContext.mockReturnValue({ filteredBlocks: undefined }); // Default to no filtering + mockUseChildBlockCounts.mockReturnValue({ data: null, isLoading: true }); // Default to loading state + mockUseAspectsSidebarContext.mockReturnValue(defaultUseAspectsSidebarContext); // Default to no filtering MockAspectsSidebar.mockClear(); // Clear calls specifically for the component mock + mockUseOutlineSidebarContext.mockReturnValue(defaultUseOutlineSidebarContext); }); const renderComponent = (props: Partial> = {}) => { @@ -173,7 +216,10 @@ describe('CourseOutlineAspectsPage', () => { it('displays graded subsections when available and no filtering is active', () => { mockUseCourseBlocks.mockReturnValue({ data: { problems: [], videos: [] } }); - mockUseAspectsSidebarContext.mockReturnValue({ filteredBlocks: [] }); + mockUseAspectsSidebarContext.mockReturnValue({ + ...defaultUseAspectsSidebarContext, + filteredBlocks: [], + }); renderComponent({ sections: mockSections }); expect(mockFormatMessage).toHaveBeenCalledWith(messages.gradedSubsectionAnalytics); @@ -185,24 +231,9 @@ describe('CourseOutlineAspectsPage', () => { { title: expectedGradedTitle, blocks: [ - { - id: 'subsection1_1', - type: 'sequential', - displayName: 'Graded Sub 1', - graded: true, - childInfo: { - category: 'vertical', children: [], displayName: 'Unit', - }, - }, - { - id: 'subsection2_1', - type: 'sequential', - displayName: 'Graded Sub 2', - graded: true, - childInfo: { - category: 'vertical', children: [], displayName: 'Unit', - }, - }, + // Only graded subsections are shown + castToBlock(mockSections[0].childInfo.children[0]), + castToBlock(mockSections[1].childInfo.children[0]), ], }, ], @@ -213,7 +244,13 @@ describe('CourseOutlineAspectsPage', () => { it('displays problems when available', () => { mockUseCourseBlocks.mockReturnValue({ data: { problems: mockProblems, videos: [] } }); - mockUseAspectsSidebarContext.mockReturnValue({ filteredBlocks: undefined }); + mockUseChildBlockCounts.mockReturnValue({ + data: { + blocks: { + ...mockProblemsBlockCount, + }, + }, + }); renderComponent({ sections: [] }); // No sections to avoid graded list expect(mockFormatMessage).toHaveBeenCalledWith(messages.problemAnalytics); @@ -234,7 +271,13 @@ describe('CourseOutlineAspectsPage', () => { it('displays videos when available', () => { mockUseCourseBlocks.mockReturnValue({ data: { problems: [], videos: mockVideos } }); - mockUseAspectsSidebarContext.mockReturnValue({ filteredBlocks: undefined }); + mockUseChildBlockCounts.mockReturnValue({ + data: { + blocks: { + ...mockVideosBlockCount, + }, + }, + }); renderComponent({ sections: [] }); // No sections expect(mockFormatMessage).toHaveBeenCalledWith(messages.videoAnalytics); @@ -255,7 +298,18 @@ describe('CourseOutlineAspectsPage', () => { it('displays all available content lists in order (graded, problems, videos) without filtering', () => { mockUseCourseBlocks.mockReturnValue({ data: { problems: mockProblems, videos: mockVideos } }); - mockUseAspectsSidebarContext.mockReturnValue({ filteredBlocks: [] }); + mockUseChildBlockCounts.mockReturnValue({ + data: { + blocks: { + ...mockProblemsBlockCount, + ...mockVideosBlockCount, + }, + }, + }); + mockUseAspectsSidebarContext.mockReturnValue({ + ...defaultUseAspectsSidebarContext, + filteredBlocks: [], + }); // Filtering active renderComponent({ sections: mockSections }); const expectedGradedTitle = messages.gradedSubsectionAnalytics.defaultMessage; @@ -288,7 +342,10 @@ describe('CourseOutlineAspectsPage', () => { it('does NOT display graded subsections when filtering is active', () => { mockUseCourseBlocks.mockReturnValue({ data: { problems: mockProblems, videos: mockVideos } }); - mockUseAspectsSidebarContext.mockReturnValue({ filteredBlocks: ['problem1'] }); // Filtering active + mockUseAspectsSidebarContext.mockReturnValue({ + ...defaultUseAspectsSidebarContext, + filteredBlocks: ['problem1'], + }); // Filtering active renderComponent({ sections: mockSections }); expect(MockAspectsSidebar.mock.calls[0][0].contentLists[0].title).not.toEqual( @@ -298,7 +355,6 @@ describe('CourseOutlineAspectsPage', () => { it('handles empty data gracefully', () => { mockUseCourseBlocks.mockReturnValue({ data: { problems: [], videos: [] } }); - mockUseAspectsSidebarContext.mockReturnValue({ filteredBlocks: undefined }); renderComponent({ sections: [] }); // No sections either expect(MockAspectsSidebar).toHaveBeenCalledWith( @@ -309,27 +365,108 @@ describe('CourseOutlineAspectsPage', () => { ); }); - it('handles null sections gracefully', () => { + it('handles the effects where we have currentItemData set to a Section', () => { mockUseCourseBlocks.mockReturnValue({ data: { problems: mockProblems, videos: [] } }); - mockUseAspectsSidebarContext.mockReturnValue({ filteredBlocks: undefined }); - // @ts-expect-error Testing null case even if type doesn't strictly allow it - renderComponent({ sections: null }); + mockUseChildBlockCounts.mockReturnValue({ + data: { + blocks: { + ...mockProblemsBlockCount, + }, + }, + error: null, + }); + mockUseAspectsSidebarContext.mockReturnValue({ + ...defaultUseAspectsSidebarContext, + filteredBlocks: [], + }); + const mockSection = mockSections[1]; + mockUseOutlineSidebarContext.mockReturnValue({ + ...defaultUseOutlineSidebarContext, + currentItemData: mockSection, + }); + + renderComponent({ sections: [] }); + + expect(defaultUseAspectsSidebarContext.setActiveBlock).toHaveBeenCalledWith(castToBlock(mockSection)); + expect(defaultUseAspectsSidebarContext.setFilteredBlocks).toHaveBeenCalledWith([]); + expect(defaultUseAspectsSidebarContext.setFilterUnit).toHaveBeenCalledWith(null); + + // Sections don't have analytics sidebar + expect(MockAspectsSidebar).toHaveBeenCalledWith( + expect.objectContaining({ + showNoAnalyticsMessage: true, + }), + {}, + ); + }); - const expectedProblemTitle = messages.problemAnalytics.defaultMessage; + it('handles the effects where we have currentItemData set to a Subsection', () => { + mockUseCourseBlocks.mockReturnValue({ data: { problems: mockProblems, videos: [] } }); + mockUseChildBlockCounts.mockReturnValue({ + data: { + blocks: { + ...mockProblemsBlockCount, + }, + }, + error: null, + }); + mockUseAspectsSidebarContext.mockReturnValue({ + ...defaultUseAspectsSidebarContext, + filteredBlocks: [], + }); + const mockSubsection = mockSections[1].childInfo.children[0]; + mockUseOutlineSidebarContext.mockReturnValue({ + ...defaultUseOutlineSidebarContext, + currentItemData: mockSubsection, + }); + + renderComponent({ sections: [] }); + + expect(defaultUseAspectsSidebarContext.setActiveBlock).toHaveBeenCalledWith(castToBlock(mockSubsection)); + expect(defaultUseAspectsSidebarContext.setFilteredBlocks).toHaveBeenCalledWith([]); + expect(defaultUseAspectsSidebarContext.setFilterUnit).toHaveBeenCalledWith(null); expect(MockAspectsSidebar).toHaveBeenCalledWith( expect.objectContaining({ - contentLists: [ - { - title: expectedProblemTitle, - blocks: mockProblems, - }, - ], + showNoAnalyticsMessage: false, + }), + {}, + ); + }); + + it('handles the effects where we have currentItemData set to a Unit', () => { + mockUseCourseBlocks.mockReturnValue({ data: { problems: mockProblems, videos: [] } }); + mockUseChildBlockCounts.mockReturnValue({ + data: { + blocks: { + ...mockProblemsBlockCount, + }, + }, + error: null, + }); + mockUseAspectsSidebarContext.mockReturnValue({ + ...defaultUseAspectsSidebarContext, + filteredBlocks: [], + }); + const mockUnit = mockSections[1].childInfo.children[0].childInfo.children![0]; + mockUseOutlineSidebarContext.mockReturnValue({ + ...defaultUseOutlineSidebarContext, + currentItemData: mockUnit, + }); + + renderComponent({ sections: [] }); + + const expectedFilteredBlocks = Object.keys(mockProblemsBlockCount); + expect(defaultUseAspectsSidebarContext.setActiveBlock).toHaveBeenCalledWith(castToBlock(mockUnit)); + expect(defaultUseAspectsSidebarContext.setFilteredBlocks).toHaveBeenCalledWith(expectedFilteredBlocks); + expect(defaultUseAspectsSidebarContext.setFilterUnit).toHaveBeenCalledWith(castToBlock(mockUnit)); + + expect(MockAspectsSidebar).toHaveBeenCalledWith( + expect.objectContaining({ + showNoAnalyticsMessage: false, }), {}, ); - // Ensure graded section title wasn't attempted - expect(mockFormatMessage).not.toHaveBeenCalledWith(messages.gradedSubsectionAnalytics); }); }); diff --git a/src/components/CourseOutlineSidebar.tsx b/src/components/CourseOutlineSidebar.tsx index be6f71ff..2fcace0f 100644 --- a/src/components/CourseOutlineSidebar.tsx +++ b/src/components/CourseOutlineSidebar.tsx @@ -85,7 +85,7 @@ export function CourseOutlineAspectsPage({ courseId, courseName, sections }: Cou // Only show the content lists if we have analytics to show if (!showNoAnalyticsMessage) { // graded subsections are shown only when unit-filtering is off - if (!filteredBlocks.length && gradedSubsections?.length) { + if (!filteredBlocks?.length && gradedSubsections?.length) { contentLists.push({ title: intl.formatMessage(messages.gradedSubsectionAnalytics), blocks: castToBlock(gradedSubsections) as Block[], diff --git a/src/components/SubSectionAnalyticsButton.test.tsx b/src/components/SubSectionAnalyticsButton.test.tsx index 43c2508e..78aa950b 100644 --- a/src/components/SubSectionAnalyticsButton.test.tsx +++ b/src/components/SubSectionAnalyticsButton.test.tsx @@ -1,16 +1,31 @@ import React from 'react'; import { userEvent } from '@testing-library/user-event'; import { render, screen } from '@testing-library/react'; + +import { + useOutlineSidebarContext, + // @ts-ignore +} from 'CourseAuthoring/course-outline/outline-sidebar/OutlineSidebarContext'; + import { SubSectionAnalyticsButton } from './SubSectionAnalyticsButton'; -import { useAspectsSidebarContext } from '../hooks'; import { SubSection } from '../types'; -import '@testing-library/jest-dom'; -// Mock the hooks module -jest.mock('../hooks', () => ({ - useAspectsSidebarContext: jest.fn(), +// Mock dependencies +jest.mock('@edx/frontend-platform/i18n', () => ({ + defineMessages: jest.fn((messageObject) => messageObject), + useIntl: () => ({ + formatMessage: (message: { defaultMessage: string }) => message.defaultMessage, + }), })); +jest.mock( + 'CourseAuthoring/course-outline/outline-sidebar/OutlineSidebarContext', + () => ({ + useOutlineSidebarContext: jest.fn(), + }), + { virtual: true }, +); + // Test Data const mockSubsections: SubSection[] = [ { @@ -44,22 +59,20 @@ const mockSubsections: SubSection[] = [ }, ]; -const mockUseAspectsSidebarContext = useAspectsSidebarContext as jest.Mock; +const mockUseOutlineSidebarContext = useOutlineSidebarContext as jest.Mock; describe('SubSectionAnalyticsButton', () => { - const mockSetActiveBlock = jest.fn(); - const mockSetFilterUnit = jest.fn(); + const mockSetCurrentPageKey = jest.fn(); + const mockSetSelectedContainerState = jest.fn(); - const defaultContextValue = { - activeBlock: null, - sidebarOpen: false, - setActiveBlock: mockSetActiveBlock, - setFilterUnit: mockSetFilterUnit, + const defaultSidebarContextValue = { + setCurrentPageKey: mockSetCurrentPageKey, + setSelectedContainerState: mockSetSelectedContainerState, }; beforeEach(() => { jest.clearAllMocks(); - mockUseAspectsSidebarContext.mockReturnValue(defaultContextValue); + mockUseOutlineSidebarContext.mockReturnValue(defaultSidebarContextValue); }); it('does not show analytics button if subsection is not graded', () => { @@ -69,7 +82,7 @@ describe('SubSectionAnalyticsButton', () => { expect(screen.queryByRole('button', { name: /analytics/i })).toBeNull(); }); - it('opens sidebar and sets active block when clicking inactive button', async () => { + it('opens sidebar and sets current block when clicking button', async () => { const mockSubsection = mockSubsections[0]; const user = userEvent.setup(); @@ -77,79 +90,9 @@ describe('SubSectionAnalyticsButton', () => { const button = screen.getByRole('button', { name: /analytics/i }); await user.click(button); - expect(mockSetActiveBlock).toHaveBeenCalledWith(expect.objectContaining({ - id: mockSubsection.id, + expect(mockSetSelectedContainerState).toHaveBeenCalledWith(expect.objectContaining({ + currentId: mockSubsection.id, })); - expect(mockSetFilterUnit).toHaveBeenCalledWith(null); - }); - - it('closes active block when clicking active button', async () => { - const mockSubsection = mockSubsections[0]; - const user = userEvent.setup(); - mockUseAspectsSidebarContext.mockReturnValue({ - ...defaultContextValue, - activeBlock: { id: mockSubsection.id } as any, - }); - - render(); - const button = screen.getByRole('button', { name: /analytics/i }); - await user.click(button); - - expect(mockSetActiveBlock).toHaveBeenCalledWith(null); - expect(mockSetFilterUnit).not.toHaveBeenCalled(); - }); - - it('shows active state when sidebar is open and block matches', async () => { - const mockSubsection = mockSubsections[2]; - const user = userEvent.setup(); - mockUseAspectsSidebarContext.mockReturnValue({ - ...defaultContextValue, - activeBlock: { id: mockSubsection.id } as any, - sidebarOpen: true, - }); - - render(); - const button = screen.getByRole('button', { name: /analytics/i }); - await user.click(button); - - expect(button.className).toContain('-active'); - }); - - it('shows inactive state when different block is active', async () => { - const mockSubsection1 = mockSubsections[2]; - const mockSubsection2 = mockSubsections[0]; - const user = userEvent.setup(); - mockUseAspectsSidebarContext.mockReturnValue({ - ...defaultContextValue, - activeBlock: { id: mockSubsection1 } as any, - sidebarOpen: true, - }); - - render(); - const button = screen.getByRole('button', { name: /analytics/i }); - await user.click(button); - - expect(mockSetActiveBlock).toHaveBeenCalledWith(expect.objectContaining({ - id: mockSubsection2.id, - })); - - expect(button.className).not.toContain('-active'); - }); - - it('shows inactive state when no block is active', async () => { - const mockSubsection = mockSubsections[0]; - const user = userEvent.setup(); - mockUseAspectsSidebarContext.mockReturnValue({ - ...defaultContextValue, - activeBlock: { id: mockSubsection } as any, - sidebarOpen: true, - }); - - const { rerender } = render(); - const button = screen.getByRole('button', { name: /analytics/i }); - await user.click(button); - - rerender(); - expect(button.className).not.toContain('-active'); + expect(mockSetCurrentPageKey).toHaveBeenCalledWith('analytics'); }); }); diff --git a/src/components/UnitActionsButton.test.tsx b/src/components/UnitActionsButton.test.tsx new file mode 100644 index 00000000..d1b8ceae --- /dev/null +++ b/src/components/UnitActionsButton.test.tsx @@ -0,0 +1,87 @@ +import React from 'react'; +import { userEvent } from '@testing-library/user-event'; +import { render, screen } from '@testing-library/react'; + +import { + useOutlineSidebarContext, + // @ts-ignore +} from 'CourseAuthoring/course-outline/outline-sidebar/OutlineSidebarContext'; + +import { useChildBlockCounts } from '../hooks'; +import { UnitActionsButton } from './UnitActionsButton'; +import type { Unit } from '../types'; + +// Mock the hooks module +jest.mock('../hooks', () => ({ + useChildBlockCounts: jest.fn(), +})); + +// Mock dependencies +jest.mock('@edx/frontend-platform/i18n', () => ({ + defineMessages: jest.fn((messageObject) => messageObject), + useIntl: () => ({ + formatMessage: (message: { defaultMessage: string }) => message.defaultMessage, + }), +})); + +jest.mock( + 'CourseAuthoring/course-outline/outline-sidebar/OutlineSidebarContext', + () => ({ + useOutlineSidebarContext: jest.fn(), + }), + { virtual: true }, +); + +// Test Data +const mockUnit: Unit = { + category: 'vertical', + displayName: 'My Unit', + graded: false, + id: 'unit-id', +}; + +const mockUseOutlineSidebarContext = useOutlineSidebarContext as jest.Mock; +const mockUseChildBlockCounts = useChildBlockCounts as jest.Mock; + +describe('UnitActionsButton', () => { + const mockSetCurrentPageKey = jest.fn(); + const mockSetSelectedContainerState = jest.fn(); + + const defaultSidebarContextValue = { + setCurrentPageKey: mockSetCurrentPageKey, + setSelectedContainerState: mockSetSelectedContainerState, + }; + + beforeEach(() => { + jest.clearAllMocks(); + mockUseOutlineSidebarContext.mockReturnValue(defaultSidebarContextValue); + }); + + it('does not show analytics button if unit does have any related blocks', () => { + mockUseChildBlockCounts.mockReturnValue({ data: { blocks: {}, root: mockUnit.id }, error: null }); + render(); + expect(screen.queryByRole('button', { name: /analytics/i })).toBeNull(); + }); + + it('opens sidebar and sets current block when clicking button', async () => { + mockUseChildBlockCounts.mockReturnValue({ + data: { + blocks: { + 'block-v1:TestX+TST101+2025@problem+block@123abc456def': {}, + }, + root: mockUnit.id, + }, + error: null, + }); + const user = userEvent.setup(); + + render(); + const button = screen.getByRole('button', { name: /analytics/i }); + await user.click(button); + + expect(mockSetSelectedContainerState).toHaveBeenCalledWith(expect.objectContaining({ + currentId: mockUnit.id, + })); + expect(mockSetCurrentPageKey).toHaveBeenCalledWith('analytics'); + }); +}); diff --git a/src/components/UnitPageSidebar.test.tsx b/src/components/UnitPageSidebar.test.tsx index 6f347b06..0d946e6d 100644 --- a/src/components/UnitPageSidebar.test.tsx +++ b/src/components/UnitPageSidebar.test.tsx @@ -1,7 +1,18 @@ import React from 'react'; -import { render } from '@testing-library/react'; -import { XBlock, Block } from '../types'; -import { UnitPageSidebar } from './UnitPageSidebar'; +import { render, screen } from '@testing-library/react'; + +import { + useUnitSidebarPagesContext, + // @ts-ignore +} from 'CourseAuthoring/course-unit/unit-sidebar/UnitSidebarPagesContext'; +import { + useUnitSidebarContext, + // @ts-ignore +} from 'CourseAuthoring/course-unit/unit-sidebar/UnitSidebarContext'; + +import { useAspectsSidebarContext } from '../hooks'; +import { type XBlock, type Block, castToBlock } from '../types'; +import { UnitOutlineAspectsPage, UnitOutlineSidebarWrapper } from './UnitPageSidebar'; import { AspectsSidebar } from './AspectsSidebar'; import { BlockTypes } from '../constants'; @@ -22,6 +33,36 @@ jest.mock( { virtual: true }, ); +jest.mock( + 'CourseAuthoring/course-unit/unit-sidebar/UnitSidebarContext', + () => ({ + useUnitSidebarContext: jest.fn(), + }), + { virtual: true }, +); + +jest.mock( + 'CourseAuthoring/course-unit/unit-sidebar/UnitSidebarPagesContext', + () => { + const mockUnitSidebarPagesContext = React.createContext({}); + const useUnitSidebarPagesContextMocked = ( + () => React.useContext(mockUnitSidebarPagesContext) as Record + ); + + return { + UnitSidebarPagesContext: mockUnitSidebarPagesContext, + useUnitSidebarPagesContext: useUnitSidebarPagesContextMocked, + }; + }, + { virtual: true }, +); + +jest.mock('../hooks', () => ({ + useCourseBlocks: jest.fn(), + useChildBlockCounts: jest.fn(), + useAspectsSidebarContext: jest.fn(), +})); + const mockBlockId = 'block-v1:TestX+TST101+2025@vertical-block:12345'; const mockUnitTitle = 'Test Unit'; const mockXBlocks: XBlock[] = [ @@ -42,22 +83,32 @@ const mockXBlocks: XBlock[] = [ }, ]; -describe('UnitPageSidebar', () => { +describe('UnitOutlineAspectsPage', () => { const MockAspectsSidebar = AspectsSidebar as jest.Mock; + const mockUseUnitSidebarContext = useUnitSidebarContext as jest.Mock; + const mockUseAspectsSidebarContext = useAspectsSidebarContext as jest.Mock; beforeEach(() => { jest.clearAllMocks(); + + mockUseUnitSidebarContext.mockReturnValue({ + selectedComponentId: undefined, + }); + mockUseAspectsSidebarContext.mockReturnValue({ + setActiveBlock: jest.fn(), + setFilteredBlocks: jest.fn(), + }); }); const renderComponent = ( - props: Partial> = {}, + props: Partial> = {}, ) => { const defaultProps = { blockId: mockBlockId, unitTitle: mockUnitTitle, xBlocks: mockXBlocks, }; - return render(); + return render(); }; it('renders the sidebar with the right props', () => { @@ -129,6 +180,17 @@ describe('UnitPageSidebar', () => { ); }); + it('filters the component blocks if we have a selected component', () => { + mockUseUnitSidebarContext.mockReturnValue({ + selectedComponentId: mockXBlocks[1].id, + }); + renderComponent(); + expect(mockUseAspectsSidebarContext().setFilteredBlocks) + .toHaveBeenCalledWith([mockXBlocks[1].id]); + expect(mockUseAspectsSidebarContext().setActiveBlock) + .toHaveBeenCalledWith(castToBlock(mockXBlocks[1])); + }); + it('calls sendMessageToIframe with the correct arguments when blockActivatedCallback is invoked', () => { renderComponent(); @@ -149,3 +211,30 @@ describe('UnitPageSidebar', () => { }); }); }); + +function MockComponent() { + const sidebarPages = useUnitSidebarPagesContext(); + + // Return a div with the title of the analytics page, reading from the context + return ( +
+ {sidebarPages.analytics.title.defaultMessage} +
+ ); +} + +describe('UnitOutlineSidebarWrapper', () => { + const renderComponent = () => render(} + pluginProps={{ + blockId: mockBlockId, + unitTitle: mockUnitTitle, + xBlocks: mockXBlocks, + }} + />); + + it('adds analytics page to the sidebar', () => { + renderComponent(); + expect(screen.getByText('Analytics')).toBeInTheDocument(); + }); +}); diff --git a/src/components/UnitPageSidebar.tsx b/src/components/UnitPageSidebar.tsx index 0437d1b4..0e9c7f99 100644 --- a/src/components/UnitPageSidebar.tsx +++ b/src/components/UnitPageSidebar.tsx @@ -19,7 +19,7 @@ import { AspectsSidebar, ContentList } from './AspectsSidebar'; interface UnitOutlineAspectsPageProps { blockId: string; unitTitle: string; - xBlocks: XBlock[]; + xBlocks: XBlock[] | null; } export function UnitOutlineAspectsPage({ @@ -38,7 +38,7 @@ export function UnitOutlineAspectsPage({ // This effect is called when the selectedComponentId changes. // It sets the activeBlock and filteredBlocks based on the selectedComponentId // and the childBlockData. - const xBlock = xBlocks.find(xblock => xblock.id === selectedComponentId); + const xBlock = xBlocks?.find(xblock => xblock.id === selectedComponentId); const block = xBlock && castToBlock(xBlock); if (block && ['problem', 'video'].includes(block.type)) { setActiveBlock(block); diff --git a/src/hooks.ts b/src/hooks.ts index e9f963f2..fd00138a 100644 --- a/src/hooks.ts +++ b/src/hooks.ts @@ -1,4 +1,3 @@ -import React from 'react'; import { camelCaseObject, getConfig } from '@edx/frontend-platform'; import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; import { useQuery, skipToken } from '@tanstack/react-query'; @@ -26,33 +25,13 @@ export type DashBoardConfig = { courseRuns: CourseRunFilterConfig[], }; -export const useDashboardConfig = (usageKey: string) => { - const [config, setConfig] = React.useState(); - const [loading, setLoading] = React.useState(true); - const [error, setError] = React.useState(''); - - React.useEffect(() => { - if (!usageKey) { - return; - } - (async () => { - setLoading(true); - setError(''); - try { - const { data } = await getAuthenticatedHttpClient() - .get(dashboardUrl(usageKey)); - setConfig(data); - setLoading(false); - } catch (e) { - setLoading(false); - setConfig(null); - setError('Dashboard not found.'); - } - })(); - }, [usageKey]); - - return { loading, error, config }; -}; +export const useDashboardConfig = (usageKey: string) => ( + useQuery({ + queryKey: ['dashboard-config', usageKey], + queryFn: usageKey ? () => getAuthenticatedHttpClient().get(dashboardUrl(usageKey)) : skipToken, + select: (response: { data: DashBoardConfig }) => (response.data), + }) +); const getCourseBlocksUrl = (courseId: string) => { const url = new URL(`${getConfig().LMS_BASE_URL}/api/courses/v1/blocks/`); diff --git a/src/setupTest.js b/src/setupTest.js index e69de29b..f813af7b 100644 --- a/src/setupTest.js +++ b/src/setupTest.js @@ -0,0 +1,4 @@ +// This file is used to configure Jest for testing. +// It is not used for running the app. +/* eslint-disable import/no-extraneous-dependencies */ +import '@testing-library/jest-dom'; diff --git a/src/types.ts b/src/types.ts index c0344a3e..a852e635 100644 --- a/src/types.ts +++ b/src/types.ts @@ -60,7 +60,7 @@ function xblockToBlock(xblock: XBlock): Block { }; } -function categoryItemToBlock(item: SubSection | Unit): Block { +function categoryItemToBlock(item: Section | SubSection | Unit): Block { const block: Block = { id: item.id, displayName: item.displayName, @@ -73,7 +73,7 @@ function categoryItemToBlock(item: SubSection | Unit): Block { return block; } -function isCategoryItem(item: SubSection | Unit | XBlock): item is SubSection | Unit { +function isCategoryItem(item: Section | SubSection | Unit | XBlock): item is Section | SubSection | Unit { return 'category' in item; } @@ -83,9 +83,9 @@ function isCategoryItem(item: SubSection | Unit | XBlock): item is SubSection | * * @param items - Various kinds of blocks received from API and Props */ -export function castToBlock(items: SubSection | Unit | XBlock): Block; +export function castToBlock(items: Section | SubSection | Unit | XBlock): Block; export function castToBlock(items: SubSection[] | XBlock[]): Block[]; -export function castToBlock(items: SubSection | SubSection[] | Unit | XBlock | XBlock[]): Block[] | Block { +export function castToBlock(items: Section | SubSection | SubSection[] | Unit | XBlock | XBlock[]): Block[] | Block { if (!Array.isArray(items)) { if (isCategoryItem(items)) { return categoryItemToBlock(items); From 051b21cf743f7e1d0fc671dd96c9bad7dc856813 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=B4mulo=20Penido?= Date: Tue, 31 Mar 2026 11:09:48 -0300 Subject: [PATCH 11/12] fix: going back from a component selected within a unit --- src/components/AspectsSidebar/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/AspectsSidebar/index.tsx b/src/components/AspectsSidebar/index.tsx index e2cd7809..eac99384 100644 --- a/src/components/AspectsSidebar/index.tsx +++ b/src/components/AspectsSidebar/index.tsx @@ -76,8 +76,8 @@ export function AspectsSidebar({ // Currently viewing component dashboard of a filtered view of a specific unit // Go back to the filtered view of the unit if (filterUnit && (activeBlock?.id !== filterUnit.id)) { + // Viewing a component inside a filtered view of a unit - go back to the filtered view of the unit setActiveBlock(filterUnit); - setFilteredBlocks([]); } else if (filterUnit?.id === activeBlock?.id) { // Viewing the filtered view of a unit - go back to full course view setActiveBlock(null); From b891036a775b2e8d84a207c96a7b7be72a11fe40 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=B4mulo=20Penido?= Date: Thu, 16 Apr 2026 12:41:45 -0300 Subject: [PATCH 12/12] chore: bump version to `3.0.0` --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 4b1808a8..0419bcf6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@openedx/frontend-plugin-aspects", - "version": "0.1.0", + "version": "3.0.0", "description": "Frontend application template", "repository": { "type": "git",