diff --git a/.env b/.env index ac79e290..bb19c65f 100644 --- a/.env +++ b/.env @@ -23,3 +23,4 @@ MFE_CONFIG_API_URL='' # Fallback in local style files PARAGON_THEME_URLS={} HOMEPAGE_PROMO_VIDEO_YOUTUBE_ID='' +SUPPORT_URL='' diff --git a/.env.development b/.env.development index 1b99bf91..bba898eb 100644 --- a/.env.development +++ b/.env.development @@ -26,3 +26,4 @@ PARAGON_THEME_URLS={} HOMEPAGE_PROMO_VIDEO_YOUTUBE_ID='test-youtube-id' ENABLE_COURSE_DISCOVERY=true ENABLE_PROGRAMS=true +SUPPORT_URL='' diff --git a/.env.test b/.env.test index f2bcc2a2..a420b881 100644 --- a/.env.test +++ b/.env.test @@ -24,3 +24,4 @@ HOMEPAGE_PROMO_VIDEO_YOUTUBE_ID='test-youtube-id' ENABLE_COURSE_DISCOVERY=true ENABLE_PROGRAMS=true INFO_EMAIL=support@example.com +SUPPORT_URL='https://support.example.com' diff --git a/src/App.test.tsx b/src/App.test.tsx index 45e26310..e0276695 100644 --- a/src/App.test.tsx +++ b/src/App.test.tsx @@ -20,6 +20,14 @@ jest.mock('./data/course-list-search/hooks', () => ({ useCourseListSearch: jest.fn(), })); +jest.mock('./header/hooks/useMenuItems', () => ({ + useMenuItems: jest.fn(() => ([])), +})); + +jest.mock('./header/hooks/useMenuItems', () => ({ + useMenuItems: jest.fn(() => ([])), +})); + const mockCourseListSearch = useCourseListSearch as jest.Mock; jest.mock('@edx/frontend-platform/react', () => ({ diff --git a/src/App.tsx b/src/App.tsx index d49808d0..d1d142f9 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -2,8 +2,8 @@ import { AppProvider } from '@edx/frontend-platform/react'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { Route, Routes } from 'react-router-dom'; import { FooterSlot } from '@edx/frontend-component-footer'; -import Header from '@edx/frontend-component-header'; +import CatalogHeader from './header/CatalogHeader'; import HomePage from './home/HomePage'; import CatalogPage from './catalog/CatalogPage'; import CourseAboutPage from './course-about/CourseAboutPage'; @@ -15,7 +15,7 @@ const queryClient = new QueryClient(); const App = () => ( -
+
} /> diff --git a/src/header/CatalogHeader.test.tsx b/src/header/CatalogHeader.test.tsx new file mode 100644 index 00000000..41c825d7 --- /dev/null +++ b/src/header/CatalogHeader.test.tsx @@ -0,0 +1,184 @@ +import { getConfig } from '@edx/frontend-platform'; + +import { render, screen } from '../setupTest'; +import { ROUTES } from '../routes'; +import CatalogHeader from './CatalogHeader'; +import { useMenuItems } from './hooks/useMenuItems'; +import messages from './messages'; + +jest.mock('@edx/frontend-platform', () => ({ + getConfig: jest.fn(() => ({ + LMS_BASE_URL: process.env.LMS_BASE_URL, + ENABLE_PROGRAMS: process.env.ENABLE_PROGRAMS, + ENABLE_COURSE_DISCOVERY: process.env.ENABLE_COURSE_DISCOVERY, + SUPPORT_URL: process.env.SUPPORT_URL, + })), + mergeConfig: jest.fn(), + ensureConfig: jest.fn(), + subscribe: jest.fn(), +})); + +jest.mock('./hooks/useMenuItems', () => ({ + useMenuItems: jest.fn(), +})); + +const mockedHeaderProps = jest.fn(); +jest.mock('@edx/frontend-component-header', () => jest.fn((props) => { + mockedHeaderProps(props); + return
Header
; +})); + +describe('CatalogHeader', () => { + const mockMenuItems = { + mainMenu: [ + { + type: 'item', + href: `${getConfig().LMS_BASE_URL}/dashboard`, + content: messages.courses.defaultMessage, + }, + ], + secondaryMenu: [ + { + type: 'item', + href: getConfig().SUPPORT_URL, + content: messages.help.defaultMessage, + }, + ], + }; + + beforeEach(() => { + (useMenuItems as jest.Mock).mockReturnValue(mockMenuItems); + jest.clearAllMocks(); + }); + + it('renders header component with correct props', () => { + render(); + + expect(screen.getByText('Header')).toBeInTheDocument(); + expect(useMenuItems).toHaveBeenCalled(); + + const props = mockedHeaderProps.mock.calls[0][0]; + expect(props.mainMenuItems).toEqual(mockMenuItems.mainMenu); + expect(props.secondaryMenuItems).toEqual(mockMenuItems.secondaryMenu); + }); + + it('passes correct main menu items to Header', () => { + render(); + + const props = mockedHeaderProps.mock.calls[0][0]; + + expect(props.mainMenuItems).toHaveLength(1); + expect(props.mainMenuItems[0].href).toBe(`${getConfig().LMS_BASE_URL}/dashboard`); + expect(props.mainMenuItems[0].content).toBe(messages.courses.defaultMessage); + }); + + it('passes correct props to Header component', () => { + const { mainMenu, secondaryMenu } = mockMenuItems; + + render(); + + expect(useMenuItems).toHaveBeenCalled(); + const hookResult = (useMenuItems as jest.Mock).mock.results[0].value; + expect(hookResult.mainMenu).toEqual(mainMenu); + expect(hookResult.secondaryMenu).toEqual(secondaryMenu); + }); + + it('handles different menu configurations', () => { + const mockMenuItemsWithPrograms = { + mainMenu: [ + ...mockMenuItems.mainMenu, + { + type: 'item', + href: `${getConfig().LMS_BASE_URL}/programs`, + content: messages.programs.defaultMessage, + }, + ], + secondaryMenu: mockMenuItems.secondaryMenu, + }; + + (useMenuItems as jest.Mock).mockReturnValue(mockMenuItemsWithPrograms); + + render(); + + const hookResult = (useMenuItems as jest.Mock).mock.results[0].value; + expect(hookResult.mainMenu).toHaveLength(2); + expect(hookResult.mainMenu[1].content).toBe(messages.programs.defaultMessage); + }); + + it('handles empty menu items', () => { + const mockEmptyMenuItems = { + mainMenu: [], + secondaryMenu: [], + }; + + (useMenuItems as jest.Mock).mockReturnValue(mockEmptyMenuItems); + + render(); + + const hookResult = (useMenuItems as jest.Mock).mock.results[0].value; + expect(hookResult.mainMenu).toHaveLength(0); + expect(hookResult.secondaryMenu).toHaveLength(0); + }); + + it('handles authenticated user menu', () => { + const mockAuthMenuItems = { + mainMenu: [ + { + type: 'item', + href: `${getConfig().LMS_BASE_URL}/dashboard`, + content: messages.courses.defaultMessage, + }, + { + type: 'item', + href: `${getConfig().LMS_BASE_URL}/programs`, + content: messages.programs.defaultMessage, + }, + ], + secondaryMenu: [ + { + type: 'item', + href: getConfig().SUPPORT_URL, + content: messages.help.defaultMessage, + }, + ], + }; + + (useMenuItems as jest.Mock).mockReturnValue(mockAuthMenuItems); + + render(); + + const hookResult = (useMenuItems as jest.Mock).mock.results[0].value; + expect(hookResult.mainMenu).toHaveLength(2); + expect(hookResult.mainMenu[0].href).toContain('/dashboard'); + expect(hookResult.mainMenu[1].href).toContain('/programs'); + }); + + it('handles non-authenticated user menu', () => { + const mockNonAuthMenuItems = { + mainMenu: [ + { + type: 'item', + href: `${getConfig().LMS_BASE_URL}${ROUTES.COURSES}`, + content: messages.exploreCourses.defaultMessage, + isActive: true, + }, + ], + secondaryMenu: [ + { + type: 'item', + href: getConfig().SUPPORT_URL, + content: messages.help.defaultMessage, + }, + ], + }; + + (useMenuItems as jest.Mock).mockReturnValue(mockNonAuthMenuItems); + + render(); + + const hookResult = (useMenuItems as jest.Mock).mock.results[0].value; + expect(hookResult.mainMenu).toHaveLength(1); + expect(hookResult.mainMenu[0].href).toContain(ROUTES.COURSES); + expect(hookResult.mainMenu[0].isActive).toBe(true); + }); +}); diff --git a/src/header/CatalogHeader.tsx b/src/header/CatalogHeader.tsx new file mode 100644 index 00000000..126c8072 --- /dev/null +++ b/src/header/CatalogHeader.tsx @@ -0,0 +1,16 @@ +import Header from '@edx/frontend-component-header'; + +import { useMenuItems } from './hooks/useMenuItems'; + +const CatalogHeader = () => { + const { mainMenu, secondaryMenu } = useMenuItems(); + + return ( +
+ ); +}; + +export default CatalogHeader; diff --git a/src/header/hooks/useMenuItems.test.tsx b/src/header/hooks/useMenuItems.test.tsx new file mode 100644 index 00000000..a2b6e913 --- /dev/null +++ b/src/header/hooks/useMenuItems.test.tsx @@ -0,0 +1,159 @@ +import { ReactNode } from 'react'; +import { useLocation } from 'react-router-dom'; +import { AppContext } from '@edx/frontend-platform/react'; +import { getConfig } from '@edx/frontend-platform'; +import { IntlProvider } from '@edx/frontend-platform/i18n'; + +import { cleanup, renderHook } from '@src/setupTest'; +import { ROUTES } from '@src/routes'; +import { AuthenticatedUserTypes } from '../types'; +import messages from '../messages'; +import { useMenuItems } from './useMenuItems'; + +const DEFAULT_CONFIG = { + LMS_BASE_URL: process.env.LMS_BASE_URL, + SUPPORT_URL: process.env.SUPPORT_URL, + ENABLE_PROGRAMS: process.env.ENABLE_PROGRAMS, + ENABLE_COURSE_DISCOVERY: process.env.ENABLE_COURSE_DISCOVERY, +}; + +jest.mock('react-router-dom', () => ({ + useLocation: jest.fn(), +})); + +jest.mock('@edx/frontend-platform', () => ({ + getConfig: jest.fn(() => DEFAULT_CONFIG), +})); + +const renderWithAppContext = (authenticatedUser: Pick | null) => { + const contextValue = { authenticatedUser }; + + return renderHook(() => useMenuItems(), { + wrapper: ({ children }: { children: ReactNode }) => ( + + + {children} + + + ), + }); +}; + +describe('useMenuItems', () => { + const mockLocation = { + pathname: ROUTES.HOME, + }; + + beforeEach(() => { + (useLocation as jest.Mock).mockReturnValue(mockLocation); + }); + + afterEach(() => { + jest.clearAllMocks(); + cleanup(); + }); + + it('should return correct menu items for non-authenticated user', () => { + const { result } = renderWithAppContext(null); + + expect(result.current.mainMenu).toHaveLength(1); + expect(result.current.mainMenu[0]).toEqual({ + type: 'item', + href: `${getConfig().LMS_BASE_URL}${ROUTES.COURSES}`, + content: messages.exploreCourses.defaultMessage, + isActive: false, + }); + }); + + it('should return correct menu items for authenticated user', () => { + const { result } = renderWithAppContext({ username: 'testuser' }); + + expect(result.current.mainMenu).toHaveLength(3); + expect(result.current.mainMenu[0]).toEqual({ + type: 'item', + href: `${getConfig().LMS_BASE_URL}/dashboard`, + content: messages.courses.defaultMessage, + }); + expect(result.current.mainMenu[1]).toEqual({ + type: 'item', + href: expect.stringContaining('/programs'), + content: messages.programs.defaultMessage, + }); + expect(result.current.mainMenu[2]).toEqual({ + type: 'item', + href: `${getConfig().LMS_BASE_URL}${ROUTES.COURSES}`, + content: messages.discoverNew.defaultMessage, + isActive: false, + }); + }); + + it('should not include programs menu item when ENABLE_PROGRAMS is false', () => { + (getConfig as jest.Mock).mockReturnValue({ + ...DEFAULT_CONFIG, + ENABLE_PROGRAMS: false, + }); + + const { result } = renderWithAppContext({ username: 'testuser' }); + + expect(result.current.mainMenu).toHaveLength(2); + expect(result.current.mainMenu.some(item => item.content === messages.programs.defaultMessage)).toBe(false); + }); + + it('should not include course discovery menu item when ENABLE_COURSE_DISCOVERY is false', () => { + (getConfig as jest.Mock).mockReturnValue({ + ...DEFAULT_CONFIG, + ENABLE_PROGRAMS: false, + ENABLE_COURSE_DISCOVERY: false, + }); + + const { result } = renderWithAppContext(null); + + expect(result.current.mainMenu).not.toContainEqual( + expect.objectContaining({ + type: 'item', + content: messages.discoverNew.defaultMessage, + }), + ); + }); + + it('should set isActive to true when navigated to the corresponding route', () => { + (getConfig as jest.Mock).mockReturnValue(DEFAULT_CONFIG); + + (useLocation as jest.Mock).mockReturnValue({ + pathname: ROUTES.COURSES, + }); + + const { result } = renderWithAppContext(null); + + expect(result.current.mainMenu[0].isActive).toBe(true); + }); + + it('should include help link in secondary menu when SUPPORT_URL is configured', () => { + const { result } = renderWithAppContext(null); + + expect(result.current.secondaryMenu).toHaveLength(1); + expect(result.current.secondaryMenu).toContainEqual({ + type: 'item', + href: getConfig().SUPPORT_URL, + content: messages.help.defaultMessage, + }); + }); + + it('should not include help link in secondary menu when SUPPORT_URL is not configured', () => { + (getConfig as jest.Mock).mockReturnValue({ + ...DEFAULT_CONFIG, + SUPPORT_URL: undefined, + ENABLE_PROGRAMS: false, + ENABLE_COURSE_DISCOVERY: false, + }); + + const { result } = renderWithAppContext(null); + + expect(result.current.secondaryMenu).not.toContainEqual( + expect.objectContaining({ + type: 'item', + content: messages.help.defaultMessage, + }), + ); + }); +}); diff --git a/src/header/hooks/useMenuItems.ts b/src/header/hooks/useMenuItems.ts new file mode 100644 index 00000000..55b8875e --- /dev/null +++ b/src/header/hooks/useMenuItems.ts @@ -0,0 +1,59 @@ +import { useContext } from 'react'; +import { useLocation } from 'react-router-dom'; +import { useIntl } from '@edx/frontend-platform/i18n'; +import { AppContext } from '@edx/frontend-platform/react'; +import { getConfig } from '@edx/frontend-platform'; + +import { ROUTES } from '@src/routes'; +import { programsUrl } from '@src/utils'; +import { AppContextTypes, MenuItem } from '../types'; +import messages from '../messages'; + +export const useMenuItems = () => { + const intl = useIntl(); + const location = useLocation(); + const { authenticatedUser } = useContext(AppContext) as AppContextTypes; + + const isCourseCatalogPage = location.pathname === ROUTES.COURSES; + + const getNotAuthenticatedUserMainMenu = (): MenuItem[] => [ + ...(getConfig().ENABLE_COURSE_DISCOVERY ? [{ + type: 'item' as const, + href: `${getConfig().LMS_BASE_URL}${ROUTES.COURSES}`, + content: intl.formatMessage(messages.exploreCourses), + isActive: isCourseCatalogPage, + }] : []), + ]; + + const getAuthenticatedUserMainMenu = (): MenuItem[] => [ + { + type: 'item' as const, + href: `${getConfig().LMS_BASE_URL}/dashboard`, + content: intl.formatMessage(messages.courses), + }, + ...(getConfig().ENABLE_PROGRAMS ? [{ + type: 'item' as const, + href: programsUrl(), + content: intl.formatMessage(messages.programs), + }] : []), + ...(!getConfig().NON_BROWSABLE_COURSES ? [{ + type: 'item' as const, + href: `${getConfig().LMS_BASE_URL}${ROUTES.COURSES}`, + content: intl.formatMessage(messages.discoverNew), + isActive: isCourseCatalogPage, + }] : []), + ]; + + const getSecondaryMenu = (): MenuItem[] => [ + ...(getConfig().SUPPORT_URL ? [{ + type: 'item' as const, + href: `${getConfig().SUPPORT_URL}`, + content: intl.formatMessage(messages.help), + }] : []), + ]; + + return { + mainMenu: authenticatedUser ? getAuthenticatedUserMainMenu() : getNotAuthenticatedUserMainMenu(), + secondaryMenu: getSecondaryMenu(), + }; +}; diff --git a/src/header/messages.ts b/src/header/messages.ts new file mode 100644 index 00000000..bb4ca0ae --- /dev/null +++ b/src/header/messages.ts @@ -0,0 +1,36 @@ +import { defineMessages } from '@edx/frontend-platform/i18n'; + +const messages = defineMessages({ + dashboard: { + id: 'category.header.dashboard.label', + defaultMessage: 'Dashboard', + description: 'The text for the link to the Dashboard page.', + }, + help: { + id: 'category.header.help.label', + defaultMessage: 'Help', + description: 'The text for the link to the Help Center.', + }, + courses: { + id: 'category.header.course', + defaultMessage: 'Courses', + description: 'The text for the link to the Courses page.', + }, + exploreCourses: { + id: 'category.header.explore-courses', + defaultMessage: 'Explore courses', + description: 'The text for the link to the Explore courses page.', + }, + programs: { + id: 'category.header.programs', + defaultMessage: 'Programs', + description: 'The text for the link to the Programs page.', + }, + discoverNew: { + id: 'category.header.discoverNew', + defaultMessage: 'Discover new', + description: 'The text for the link to the Discover new page.', + }, +}); + +export default messages; diff --git a/src/header/types.ts b/src/header/types.ts new file mode 100644 index 00000000..e936aa9e --- /dev/null +++ b/src/header/types.ts @@ -0,0 +1,24 @@ +export interface AuthenticatedUserTypes { + email: string; + userId: number; + username: string; + roles: string[]; + administrator: boolean; + name: string; +} + +export interface ConfigTypes { + [key: string]: string | boolean | number | Record; +} + +export interface AppContextTypes { + authenticatedUser: AuthenticatedUserTypes | null; + config: ConfigTypes; +} + +export interface MenuItem { + type: 'item'; + href: string; + content: string; + isActive?: boolean; +} diff --git a/src/utils.ts b/src/utils.ts new file mode 100644 index 00000000..9de9257e --- /dev/null +++ b/src/utils.ts @@ -0,0 +1,17 @@ +import { getConfig } from '@edx/frontend-platform'; + +/** + * Resolves a URL by combining it with a base URL if it's relative. + * If the URL is null or absolute (starts with http:// or https://), it is returned as is. + */ +export const resolveUrl = (base: string, url: string) => ((url == null || url.startsWith('http://') || url.startsWith('https://')) ? url : `${base}${url}`); + +/** + * Creates a full URL by combining the LMS base URL with a relative path. + */ +export const baseAppUrl = (url: string) => resolveUrl(getConfig().LMS_BASE_URL, url); + +/** + * Gets the URL for the programs dashboard page. + */ +export const programsUrl = () => baseAppUrl('/dashboard/programs');