From 7e88d0c57a6dc053732880d27760daac38ef70e6 Mon Sep 17 00:00:00 2001 From: PKulkoRaccoonGang Date: Tue, 16 Sep 2025 18:38:09 +0300 Subject: [PATCH 01/10] feat: updated Header links --- src/App.tsx | 4 +- src/config/index.ts | 12 ++++++ src/generic/course-card/index.tsx | 1 + src/header/CatalogHeader.tsx | 22 +++++++++++ src/header/hooks/useMenuItems.ts | 62 +++++++++++++++++++++++++++++++ src/header/messages.ts | 36 ++++++++++++++++++ src/header/types.ts | 24 ++++++++++++ src/index.tsx | 10 ++++- src/utils.ts | 22 +++++++++++ 9 files changed, 190 insertions(+), 3 deletions(-) create mode 100644 src/config/index.ts create mode 100644 src/header/CatalogHeader.tsx create mode 100644 src/header/hooks/useMenuItems.ts create mode 100644 src/header/messages.ts create mode 100644 src/header/types.ts create mode 100644 src/utils.ts 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/config/index.ts b/src/config/index.ts new file mode 100644 index 00000000..bfec5df1 --- /dev/null +++ b/src/config/index.ts @@ -0,0 +1,12 @@ +const configuration = { + LMS_BASE_URL: process.env.LMS_BASE_URL, + SITE_NAME: process.env.SITE_NAME, + // SUPPORT_URL: process.env.SUPPORT_URL || null, + INFO_EMAIL: process.env.INFO_EMAIL || '', + LOGO_URL: process.env.LOGO_URL, + ENABLE_PROGRAMS: process.env.ENABLE_PROGRAMS === 'true', +}; + +const features = {}; + +export { configuration, features }; diff --git a/src/generic/course-card/index.tsx b/src/generic/course-card/index.tsx index fbfb4832..5ce62f15 100644 --- a/src/generic/course-card/index.tsx +++ b/src/generic/course-card/index.tsx @@ -6,6 +6,7 @@ import { useIntl } from '@edx/frontend-platform/i18n'; import noCourseImg from '@src/assets/images/no-course-image.svg'; +import { ROUTES } from '@src/routes'; import { CourseCardProps } from './types'; import messages from './messages'; import { getFullImageUrl, getStartDateDisplay } from './utils'; diff --git a/src/header/CatalogHeader.tsx b/src/header/CatalogHeader.tsx new file mode 100644 index 00000000..aa8e6230 --- /dev/null +++ b/src/header/CatalogHeader.tsx @@ -0,0 +1,22 @@ +import Header from '@edx/frontend-component-header'; +// import { getConfig } from '@edx/frontend-platform'; + +import { useMenuItems } from './hooks/useMenuItems'; + +const CatalogHeader = () => { + const { + mainMenu, + secondaryMenu, + // isNotHomePage, + } = useMenuItems(); + + return ( +
+ ); +}; + +export default CatalogHeader; diff --git a/src/header/hooks/useMenuItems.ts b/src/header/hooks/useMenuItems.ts new file mode 100644 index 00000000..00aebc21 --- /dev/null +++ b/src/header/hooks/useMenuItems.ts @@ -0,0 +1,62 @@ +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 isCourseAboutPage = location.pathname.includes(ROUTES.COURSE_ABOUT); + const isNotHomePage = isCourseCatalogPage || isCourseAboutPage; + + 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(), + isNotHomePage, + }; +}; diff --git a/src/header/messages.ts b/src/header/messages.ts new file mode 100644 index 00000000..0a316a57 --- /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: 'Header link for switching to Dashboard page.', + }, + exploreCourses: { + id: 'category.header.explore-courses', + defaultMessage: 'Explore courses', + description: 'Header link for switching to Dashboard page.', + }, + programs: { + id: 'category.header.programs', + defaultMessage: 'Programs', + description: 'Header link for switching to Programs page.', + }, + discoverNew: { + id: 'category.header.discoverNew', + defaultMessage: 'Discover new', + description: 'Header link for switching to Course Catalog page.', + }, +}); + +export default messages; diff --git a/src/header/types.ts b/src/header/types.ts new file mode 100644 index 00000000..1c1b0fac --- /dev/null +++ b/src/header/types.ts @@ -0,0 +1,24 @@ +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/index.tsx b/src/index.tsx index 67d0b698..10df2c8d 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -2,11 +2,12 @@ import 'core-js/stable'; import 'regenerator-runtime/runtime'; import { - APP_INIT_ERROR, APP_READY, subscribe, initialize, + APP_INIT_ERROR, APP_READY, subscribe, initialize, mergeConfig, } from '@edx/frontend-platform'; import { ErrorPage } from '@edx/frontend-platform/react'; import { createRoot } from 'react-dom/client'; +import { configuration } from './config'; import App from './App'; import messages from './i18n'; @@ -23,6 +24,13 @@ subscribe(APP_INIT_ERROR, (error: { message: any; }) => { root.render(); }); +export const appName = 'CatalogAppConfig'; + initialize({ + handlers: { + config: () => { + mergeConfig(configuration, appName); + }, + }, messages, }); diff --git a/src/utils.ts b/src/utils.ts new file mode 100644 index 00000000..132f034c --- /dev/null +++ b/src/utils.ts @@ -0,0 +1,22 @@ +import { getConfig } from '@edx/frontend-platform'; + +/** + * Gets the base URL for the LMS from the frontend platform configuration. + */ +export const getBaseUrl = () => getConfig().LMS_BASE_URL; + +/** + * 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(getBaseUrl(), url); + +/** + * Gets the URL for the programs dashboard page. + */ +export const programsUrl = () => baseAppUrl('/dashboard/programs'); From cda6af9389863b0b7cb6ca7e9be9d434a94dad62 Mon Sep 17 00:00:00 2001 From: PKulkoRaccoonGang Date: Wed, 24 Sep 2025 11:07:31 +0300 Subject: [PATCH 02/10] refactor: some refactoring --- src/App.test.tsx | 8 + src/config/index.ts | 12 -- src/generic/course-card/index.tsx | 1 - src/header/CatalogHeader.test.tsx | 285 +++++++++++++++++++++++++ src/header/CatalogHeader.tsx | 6 +- src/header/hooks/useMenuItems.test.tsx | 172 +++++++++++++++ src/index.tsx | 14 +- 7 files changed, 470 insertions(+), 28 deletions(-) delete mode 100644 src/config/index.ts create mode 100644 src/header/CatalogHeader.test.tsx create mode 100644 src/header/hooks/useMenuItems.test.tsx 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/config/index.ts b/src/config/index.ts deleted file mode 100644 index bfec5df1..00000000 --- a/src/config/index.ts +++ /dev/null @@ -1,12 +0,0 @@ -const configuration = { - LMS_BASE_URL: process.env.LMS_BASE_URL, - SITE_NAME: process.env.SITE_NAME, - // SUPPORT_URL: process.env.SUPPORT_URL || null, - INFO_EMAIL: process.env.INFO_EMAIL || '', - LOGO_URL: process.env.LOGO_URL, - ENABLE_PROGRAMS: process.env.ENABLE_PROGRAMS === 'true', -}; - -const features = {}; - -export { configuration, features }; diff --git a/src/generic/course-card/index.tsx b/src/generic/course-card/index.tsx index 5ce62f15..fbfb4832 100644 --- a/src/generic/course-card/index.tsx +++ b/src/generic/course-card/index.tsx @@ -6,7 +6,6 @@ import { useIntl } from '@edx/frontend-platform/i18n'; import noCourseImg from '@src/assets/images/no-course-image.svg'; -import { ROUTES } from '@src/routes'; import { CourseCardProps } from './types'; import messages from './messages'; import { getFullImageUrl, getStartDateDisplay } from './utils'; diff --git a/src/header/CatalogHeader.test.tsx b/src/header/CatalogHeader.test.tsx new file mode 100644 index 00000000..da3618f2 --- /dev/null +++ b/src/header/CatalogHeader.test.tsx @@ -0,0 +1,285 @@ +import { getConfig, mergeConfig } from '@edx/frontend-platform'; +import { AppContext } from '@edx/frontend-platform/react'; + +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: true, + ENABLE_COURSE_DISCOVERY: true, + })), + ensureConfig: jest.fn(), + mergeConfig: jest.fn(), +})); + +jest.mock('@edx/frontend-component-header', () => ({ + __esModule: true, + default: jest.fn(({ mainMenuItems, logoDestination, secondaryMenuItems }) => ( +
+
{JSON.stringify(mainMenuItems)}
+
{logoDestination}
+
{JSON.stringify(secondaryMenuItems)}
+
+ )), +})); + +jest.mock('./hooks/useMenuItems', () => ({ + useMenuItems: jest.fn(), +})); + +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, + }, + ], + isNotHomePage: false, + }; + + beforeEach(() => { + (useMenuItems as jest.Mock).mockReturnValue(mockMenuItems); + }); + + afterEach(() => jest.clearAllMocks()); + + it('renders header with correct props', () => { + render(); + + expect(screen.getByTestId('header')).toBeInTheDocument(); + expect(screen.getByTestId('main-menu')).toHaveTextContent(JSON.stringify(mockMenuItems.mainMenu)); + expect(screen.getByTestId('secondary-menu')).toHaveTextContent(JSON.stringify(mockMenuItems.secondaryMenu)); + expect(screen.getByTestId('logo-destination')).toHaveTextContent(getConfig().LMS_BASE_URL); + }); + + it('should display Help link if SUPPORT_URL is set', () => { + mergeConfig({ SUPPORT_URL: getConfig().SUPPORT_URL }); + render(); + const secondaryMenuText = screen.getByTestId('secondary-menu').textContent; + const secondaryMenu = secondaryMenuText ? JSON.parse(secondaryMenuText) : []; + + expect(secondaryMenu).toHaveLength(1); + expect(secondaryMenu[0].href).toBe(getConfig().SUPPORT_URL); + }); + + it('should display Programs link if it is enabled by configuration', () => { + const mockMenuItemsWithPrograms = { + ...mockMenuItems, + mainMenu: [ + ...mockMenuItems.mainMenu, + { + type: 'item', + href: `${getConfig().LMS_BASE_URL}/programs`, + content: messages.programs.defaultMessage, + }, + ], + }; + (useMenuItems as jest.Mock).mockReturnValue(mockMenuItemsWithPrograms); + + render(); + const mainMenuText = screen.getByTestId('main-menu').textContent; + const mainMenu = mainMenuText ? JSON.parse(mainMenuText) : []; + + expect(mainMenu).toContainEqual( + expect.objectContaining({ + href: expect.stringContaining('/programs'), + }), + ); + }); + + it('should not display Discover New tab if course discovery is disabled', () => { + const mockMenuItemsWithoutDiscovery = { + ...mockMenuItems, + mainMenu: mockMenuItems.mainMenu.filter(item => !item.href.includes(ROUTES.COURSES)), + }; + (useMenuItems as jest.Mock).mockReturnValue(mockMenuItemsWithoutDiscovery); + + render(); + const mainMenuText = screen.getByTestId('main-menu').textContent; + const mainMenu = mainMenuText ? JSON.parse(mainMenuText) : []; + + expect(mainMenu).not.toContainEqual( + expect.objectContaining({ + href: expect.stringContaining(ROUTES.COURSES), + }), + ); + }); + + it('should not display Help link if SUPPORT_URL is not set', () => { + mergeConfig({ SUPPORT_URL: undefined }); + const mockMenuItemsWithoutHelp = { + ...mockMenuItems, + secondaryMenu: [], + }; + (useMenuItems as jest.Mock).mockReturnValue(mockMenuItemsWithoutHelp); + + render(); + const secondaryMenuText = screen.getByTestId('secondary-menu').textContent; + const secondaryMenu = secondaryMenuText ? JSON.parse(secondaryMenuText) : []; + + expect(secondaryMenu).toHaveLength(0); + }); + + it('should display active state for current page menu item', () => { + const mockMenuItemsWithActive = { + ...mockMenuItems, + mainMenu: [ + { + type: 'item', + href: `${getConfig().LMS_BASE_URL}/dashboard`, + content: messages.courses.defaultMessage, + isActive: true, + }, + ], + }; + (useMenuItems as jest.Mock).mockReturnValue(mockMenuItemsWithActive); + + render(); + const mainMenuText = screen.getByTestId('main-menu').textContent; + const mainMenu = mainMenuText ? JSON.parse(mainMenuText) : []; + + expect(mainMenu[0].isActive).toBe(true); + }); + + it('should handle empty menu items gracefully', () => { + const mockEmptyMenuItems = { + mainMenu: [], + secondaryMenu: [], + isNotHomePage: false, + }; + (useMenuItems as jest.Mock).mockReturnValue(mockEmptyMenuItems); + + render(); + const mainMenuText = screen.getByTestId('main-menu').textContent; + const secondaryMenuText = screen.getByTestId('secondary-menu').textContent; + const mainMenu = mainMenuText ? JSON.parse(mainMenuText) : []; + const secondaryMenu = secondaryMenuText ? JSON.parse(secondaryMenuText) : []; + + expect(mainMenu).toHaveLength(0); + expect(secondaryMenu).toHaveLength(0); + }); + + it('should display Explore courses link when course discovery is enabled', () => { + const mockMenuItemsWithExploreCourses = { + ...mockMenuItems, + mainMenu: [ + ...mockMenuItems.mainMenu, + { + type: 'item', + href: `${getConfig().LMS_BASE_URL}${ROUTES.COURSES}`, + content: messages.exploreCourses.defaultMessage, + isActive: false, + }, + ], + }; + (useMenuItems as jest.Mock).mockReturnValue(mockMenuItemsWithExploreCourses); + mergeConfig({ ENABLE_COURSE_DISCOVERY: true }); + + render(); + const mainMenuText = screen.getByTestId('main-menu').textContent; + const mainMenu = mainMenuText ? JSON.parse(mainMenuText) : []; + + expect(mainMenu).toContainEqual( + expect.objectContaining({ + href: expect.stringContaining(ROUTES.COURSES), + content: messages.exploreCourses.defaultMessage, + }), + ); + }); + + it('should display correct menu items for authenticated user', () => { + const authenticatedUser = { username: 'testuser' }; + const mockMenuItemsForAuth = { + 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, + }, + { + type: 'item', + href: `${getConfig().LMS_BASE_URL}${ROUTES.COURSES}`, + content: messages.discoverNew.defaultMessage, + }, + ], + secondaryMenu: [ + { + type: 'item', + href: getConfig().SUPPORT_URL, + content: messages.help.defaultMessage, + }, + ], + isNotHomePage: false, + }; + (useMenuItems as jest.Mock).mockReturnValue(mockMenuItemsForAuth); + + render( + + + , + ); + + const mainMenuText = screen.getByTestId('main-menu').textContent; + const mainMenu = mainMenuText ? JSON.parse(mainMenuText) : []; + + expect(mainMenu).toHaveLength(3); + expect(mainMenu[0].href).toBe(`${getConfig().LMS_BASE_URL}/dashboard`); + expect(mainMenu[1].href).toBe(`${getConfig().LMS_BASE_URL}/programs`); + expect(mainMenu[2].href).toBe(`${getConfig().LMS_BASE_URL}${ROUTES.COURSES}`); + }); + + it('should display correct menu items for non-authenticated user', () => { + const mockMenuItemsForNonAuth = { + 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, + }, + ], + isNotHomePage: false, + }; + (useMenuItems as jest.Mock).mockReturnValue(mockMenuItemsForNonAuth); + + render( + + + , + ); + + const mainMenuText = screen.getByTestId('main-menu').textContent; + const mainMenu = mainMenuText ? JSON.parse(mainMenuText) : []; + + expect(mainMenu).toHaveLength(1); + expect(mainMenu[0].href).toBe(`${getConfig().LMS_BASE_URL}${ROUTES.COURSES}`); + expect(mainMenu[0].content).toBe(messages.exploreCourses.defaultMessage); + expect(mainMenu[0].isActive).toBe(true); + }); +}); diff --git a/src/header/CatalogHeader.tsx b/src/header/CatalogHeader.tsx index aa8e6230..3a3eeb13 100644 --- a/src/header/CatalogHeader.tsx +++ b/src/header/CatalogHeader.tsx @@ -1,5 +1,5 @@ import Header from '@edx/frontend-component-header'; -// import { getConfig } from '@edx/frontend-platform'; +import { getConfig } from '@edx/frontend-platform'; import { useMenuItems } from './hooks/useMenuItems'; @@ -7,13 +7,13 @@ const CatalogHeader = () => { const { mainMenu, secondaryMenu, - // isNotHomePage, + isNotHomePage, } = useMenuItems(); return (
); diff --git a/src/header/hooks/useMenuItems.test.tsx b/src/header/hooks/useMenuItems.test.tsx new file mode 100644 index 00000000..d173a06c --- /dev/null +++ b/src/header/hooks/useMenuItems.test.tsx @@ -0,0 +1,172 @@ +import { ReactNode, useMemo } 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 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: true, + ENABLE_COURSE_DISCOVERY: true, + COURSES_ARE_BROWSABLE: true, +}; + +jest.mock('react-router-dom', () => ({ + useLocation: jest.fn(), +})); + +jest.mock('@edx/frontend-platform', () => ({ + getConfig: jest.fn(() => DEFAULT_CONFIG), + ensureConfig: jest.fn(), + mergeConfig: jest.fn(), + setConfig: jest.fn(), +})); + +const createWrapper = (authenticatedUser: { + username: string } | null) => function Wrapper({ children }: { children: ReactNode }) { + const contextValue = useMemo(() => ({ authenticatedUser }), [authenticatedUser]); + + return ( + + + {children} + + + ); +}; + +describe('useMenuItems', () => { + const mockLocation = { + pathname: '/', + }; + + beforeEach(() => { + (useLocation as jest.Mock).mockReturnValue(mockLocation); + }); + + afterEach(() => { + jest.clearAllMocks(); + cleanup(); + }); + + it('should return correct menu items for non-authenticated user', () => { + const { result } = renderHook(() => useMenuItems(), { wrapper: createWrapper(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 } = renderHook(() => useMenuItems(), { wrapper: createWrapper({ 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 } = renderHook(() => useMenuItems(), { wrapper: createWrapper({ 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 } = renderHook(() => useMenuItems(), { wrapper: createWrapper(null) }); + + expect(result.current.mainMenu).toHaveLength(0); + }); + + it('should set isActive to true when on course catalog page', () => { + (getConfig as jest.Mock).mockReturnValue(DEFAULT_CONFIG); + + (useLocation as jest.Mock).mockReturnValue({ + pathname: ROUTES.COURSES, + }); + + const { result } = renderHook(() => useMenuItems(), { wrapper: createWrapper(null) }); + + expect(result.current.mainMenu[0].isActive).toBe(true); + }); + + it('should include help link in secondary menu when SUPPORT_URL is configured', () => { + const { result } = renderHook(() => useMenuItems(), { wrapper: createWrapper(null) }); + + expect(result.current.secondaryMenu).toHaveLength(1); + expect(result.current.secondaryMenu[0]).toEqual({ + 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 } = renderHook(() => useMenuItems(), { wrapper: createWrapper(null) }); + + expect(result.current.secondaryMenu).toHaveLength(0); + }); + + it('should set isNotHomePage to true when on course catalog page', () => { + (useLocation as jest.Mock).mockReturnValue({ + pathname: ROUTES.COURSES, + }); + + const { result } = renderHook(() => useMenuItems(), { wrapper: createWrapper(null) }); + + expect(result.current.isNotHomePage).toBe(true); + }); + + it('should set isNotHomePage to true when on course about page', () => { + (useLocation as jest.Mock).mockReturnValue({ + pathname: `${ROUTES.COURSE_ABOUT}/some-course`, + }); + + const { result } = renderHook(() => useMenuItems(), { wrapper: createWrapper(null) }); + + expect(result.current.isNotHomePage).toBe(true); + }); +}); diff --git a/src/index.tsx b/src/index.tsx index 10df2c8d..16617f14 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -2,12 +2,11 @@ import 'core-js/stable'; import 'regenerator-runtime/runtime'; import { - APP_INIT_ERROR, APP_READY, subscribe, initialize, mergeConfig, + APP_INIT_ERROR, APP_READY, subscribe, initialize, } from '@edx/frontend-platform'; import { ErrorPage } from '@edx/frontend-platform/react'; import { createRoot } from 'react-dom/client'; -import { configuration } from './config'; import App from './App'; import messages from './i18n'; @@ -24,13 +23,4 @@ subscribe(APP_INIT_ERROR, (error: { message: any; }) => { root.render(); }); -export const appName = 'CatalogAppConfig'; - -initialize({ - handlers: { - config: () => { - mergeConfig(configuration, appName); - }, - }, - messages, -}); +initialize({ messages }); From 36660d8dd3ca2a1d508d01083bd3fa3974f1065b Mon Sep 17 00:00:00 2001 From: PKulkoRaccoonGang Date: Wed, 24 Sep 2025 11:25:41 +0300 Subject: [PATCH 03/10] refactor: code refactoring --- src/header/CatalogHeader.test.tsx | 5 +-- src/header/hooks/useMenuItems.test.tsx | 55 ++++++++++++-------------- src/header/hooks/useMenuItems.ts | 2 +- src/header/types.ts | 2 +- src/index.tsx | 4 +- src/utils.ts | 7 +--- 6 files changed, 34 insertions(+), 41 deletions(-) diff --git a/src/header/CatalogHeader.test.tsx b/src/header/CatalogHeader.test.tsx index da3618f2..273a156d 100644 --- a/src/header/CatalogHeader.test.tsx +++ b/src/header/CatalogHeader.test.tsx @@ -10,10 +10,9 @@ import messages from './messages'; jest.mock('@edx/frontend-platform', () => ({ getConfig: jest.fn(() => ({ LMS_BASE_URL: process.env.LMS_BASE_URL, - ENABLE_PROGRAMS: true, - ENABLE_COURSE_DISCOVERY: true, + ENABLE_PROGRAMS: process.env.ENABLE_PROGRAMS, + ENABLE_COURSE_DISCOVERY: process.env.ENABLE_COURSE_DISCOVERY, })), - ensureConfig: jest.fn(), mergeConfig: jest.fn(), })); diff --git a/src/header/hooks/useMenuItems.test.tsx b/src/header/hooks/useMenuItems.test.tsx index d173a06c..3a53f9ee 100644 --- a/src/header/hooks/useMenuItems.test.tsx +++ b/src/header/hooks/useMenuItems.test.tsx @@ -1,21 +1,20 @@ -import { ReactNode, useMemo } from 'react'; +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: true, - ENABLE_COURSE_DISCOVERY: true, - COURSES_ARE_BROWSABLE: true, + ENABLE_PROGRAMS: process.env.ENABLE_PROGRAMS, + ENABLE_COURSE_DISCOVERY: process.env.ENABLE_COURSE_DISCOVERY, }; jest.mock('react-router-dom', () => ({ @@ -24,27 +23,25 @@ jest.mock('react-router-dom', () => ({ jest.mock('@edx/frontend-platform', () => ({ getConfig: jest.fn(() => DEFAULT_CONFIG), - ensureConfig: jest.fn(), - mergeConfig: jest.fn(), - setConfig: jest.fn(), })); -const createWrapper = (authenticatedUser: { - username: string } | null) => function Wrapper({ children }: { children: ReactNode }) { - const contextValue = useMemo(() => ({ authenticatedUser }), [authenticatedUser]); - - return ( - - - {children} - - - ); +const renderWithAppContext = (authenticatedUser: Pick | null) => { + const contextValue = { authenticatedUser }; + + return renderHook(() => useMenuItems(), { + wrapper: ({ children }: { children: ReactNode }) => ( + + + {children} + + + ), + }); }; describe('useMenuItems', () => { const mockLocation = { - pathname: '/', + pathname: ROUTES.HOME, }; beforeEach(() => { @@ -57,7 +54,7 @@ describe('useMenuItems', () => { }); it('should return correct menu items for non-authenticated user', () => { - const { result } = renderHook(() => useMenuItems(), { wrapper: createWrapper(null) }); + const { result } = renderWithAppContext(null); expect(result.current.mainMenu).toHaveLength(1); expect(result.current.mainMenu[0]).toEqual({ @@ -69,7 +66,7 @@ describe('useMenuItems', () => { }); it('should return correct menu items for authenticated user', () => { - const { result } = renderHook(() => useMenuItems(), { wrapper: createWrapper({ username: 'testuser' }) }); + const { result } = renderWithAppContext({ username: 'testuser' }); expect(result.current.mainMenu).toHaveLength(3); expect(result.current.mainMenu[0]).toEqual({ @@ -96,7 +93,7 @@ describe('useMenuItems', () => { ENABLE_PROGRAMS: false, }); - const { result } = renderHook(() => useMenuItems(), { wrapper: createWrapper({ username: 'testuser' }) }); + const { result } = renderWithAppContext({ username: 'testuser' }); expect(result.current.mainMenu).toHaveLength(2); expect(result.current.mainMenu.some(item => item.content === messages.programs.defaultMessage)).toBe(false); @@ -109,7 +106,7 @@ describe('useMenuItems', () => { ENABLE_COURSE_DISCOVERY: false, }); - const { result } = renderHook(() => useMenuItems(), { wrapper: createWrapper(null) }); + const { result } = renderWithAppContext(null); expect(result.current.mainMenu).toHaveLength(0); }); @@ -121,13 +118,13 @@ describe('useMenuItems', () => { pathname: ROUTES.COURSES, }); - const { result } = renderHook(() => useMenuItems(), { wrapper: createWrapper(null) }); + 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 } = renderHook(() => useMenuItems(), { wrapper: createWrapper(null) }); + const { result } = renderWithAppContext(null); expect(result.current.secondaryMenu).toHaveLength(1); expect(result.current.secondaryMenu[0]).toEqual({ @@ -145,7 +142,7 @@ describe('useMenuItems', () => { ENABLE_COURSE_DISCOVERY: false, }); - const { result } = renderHook(() => useMenuItems(), { wrapper: createWrapper(null) }); + const { result } = renderWithAppContext(null); expect(result.current.secondaryMenu).toHaveLength(0); }); @@ -155,7 +152,7 @@ describe('useMenuItems', () => { pathname: ROUTES.COURSES, }); - const { result } = renderHook(() => useMenuItems(), { wrapper: createWrapper(null) }); + const { result } = renderWithAppContext(null); expect(result.current.isNotHomePage).toBe(true); }); @@ -165,7 +162,7 @@ describe('useMenuItems', () => { pathname: `${ROUTES.COURSE_ABOUT}/some-course`, }); - const { result } = renderHook(() => useMenuItems(), { wrapper: createWrapper(null) }); + const { result } = renderWithAppContext(null); expect(result.current.isNotHomePage).toBe(true); }); diff --git a/src/header/hooks/useMenuItems.ts b/src/header/hooks/useMenuItems.ts index 00aebc21..8c043fac 100644 --- a/src/header/hooks/useMenuItems.ts +++ b/src/header/hooks/useMenuItems.ts @@ -35,7 +35,7 @@ export const useMenuItems = () => { }, ...(getConfig().ENABLE_PROGRAMS ? [{ type: 'item' as const, - href: `${programsUrl()}`, + href: programsUrl(), content: intl.formatMessage(messages.programs), }] : []), ...(!getConfig().NON_BROWSABLE_COURSES ? [{ diff --git a/src/header/types.ts b/src/header/types.ts index 1c1b0fac..e936aa9e 100644 --- a/src/header/types.ts +++ b/src/header/types.ts @@ -1,4 +1,4 @@ -interface AuthenticatedUserTypes { +export interface AuthenticatedUserTypes { email: string; userId: number; username: string; diff --git a/src/index.tsx b/src/index.tsx index 16617f14..67d0b698 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -23,4 +23,6 @@ subscribe(APP_INIT_ERROR, (error: { message: any; }) => { root.render(); }); -initialize({ messages }); +initialize({ + messages, +}); diff --git a/src/utils.ts b/src/utils.ts index 132f034c..9de9257e 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,10 +1,5 @@ import { getConfig } from '@edx/frontend-platform'; -/** - * Gets the base URL for the LMS from the frontend platform configuration. - */ -export const getBaseUrl = () => getConfig().LMS_BASE_URL; - /** * 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. @@ -14,7 +9,7 @@ export const resolveUrl = (base: string, url: string) => ((url == null || url.st /** * Creates a full URL by combining the LMS base URL with a relative path. */ -export const baseAppUrl = (url: string) => resolveUrl(getBaseUrl(), url); +export const baseAppUrl = (url: string) => resolveUrl(getConfig().LMS_BASE_URL, url); /** * Gets the URL for the programs dashboard page. From 44ae8b5ffcfb83e628e2e842d4be3003899ff92e Mon Sep 17 00:00:00 2001 From: PKulkoRaccoonGang Date: Wed, 24 Sep 2025 13:49:46 +0300 Subject: [PATCH 04/10] refactor: added some tests --- src/header/CatalogHeader.test.tsx | 57 +++++++++++++++++++++++++------ src/header/CatalogHeader.tsx | 6 ++-- src/header/hooks/useMenuItems.ts | 1 + src/header/utils.ts | 21 ++++++++++++ 4 files changed, 72 insertions(+), 13 deletions(-) create mode 100644 src/header/utils.ts diff --git a/src/header/CatalogHeader.test.tsx b/src/header/CatalogHeader.test.tsx index 273a156d..78289e7c 100644 --- a/src/header/CatalogHeader.test.tsx +++ b/src/header/CatalogHeader.test.tsx @@ -1,5 +1,4 @@ import { getConfig, mergeConfig } from '@edx/frontend-platform'; -import { AppContext } from '@edx/frontend-platform/react'; import { render, screen } from '../setupTest'; import { ROUTES } from '../routes'; @@ -203,6 +202,7 @@ describe('CatalogHeader', () => { it('should display correct menu items for authenticated user', () => { const authenticatedUser = { username: 'testuser' }; const mockMenuItemsForAuth = { + authenticatedUser, mainMenu: [ { type: 'item', @@ -231,11 +231,7 @@ describe('CatalogHeader', () => { }; (useMenuItems as jest.Mock).mockReturnValue(mockMenuItemsForAuth); - render( - - - , - ); + render(); const mainMenuText = screen.getByTestId('main-menu').textContent; const mainMenu = mainMenuText ? JSON.parse(mainMenuText) : []; @@ -248,6 +244,7 @@ describe('CatalogHeader', () => { it('should display correct menu items for non-authenticated user', () => { const mockMenuItemsForNonAuth = { + authenticatedUser: null, mainMenu: [ { type: 'item', @@ -267,11 +264,7 @@ describe('CatalogHeader', () => { }; (useMenuItems as jest.Mock).mockReturnValue(mockMenuItemsForNonAuth); - render( - - - , - ); + render(); const mainMenuText = screen.getByTestId('main-menu').textContent; const mainMenu = mainMenuText ? JSON.parse(mainMenuText) : []; @@ -281,4 +274,46 @@ describe('CatalogHeader', () => { expect(mainMenu[0].content).toBe(messages.exploreCourses.defaultMessage); expect(mainMenu[0].isActive).toBe(true); }); + + describe('logoDestination logic', () => { + it('should set logoDestination to LMS_BASE_URL for non-authenticated user on home page', () => { + const mockMenuItemsForNonAuth = { + ...mockMenuItems, + authenticatedUser: null, + isNotHomePage: false, + }; + (useMenuItems as jest.Mock).mockReturnValue(mockMenuItemsForNonAuth); + + render(); + + expect(screen.getByTestId('logo-destination')).toHaveTextContent(getConfig().LMS_BASE_URL); + }); + + it('should set logoDestination to /catalog/ for authenticated user on home page', () => { + const authenticatedUser = { username: 'testuser' }; + const mockMenuItemsForAuth = { + ...mockMenuItems, + authenticatedUser, + isNotHomePage: false, + }; + (useMenuItems as jest.Mock).mockReturnValue(mockMenuItemsForAuth); + + render(); + + expect(screen.getByTestId('logo-destination')).toHaveTextContent(`/${process.env.APP_ID}/`); + }); + + it('should set logoDestination to undefined for any user on non-home page', () => { + const mockMenuItemsForNonHomePage = { + ...mockMenuItems, + authenticatedUser: { username: 'testuser' }, + isNotHomePage: true, + }; + (useMenuItems as jest.Mock).mockReturnValue(mockMenuItemsForNonHomePage); + + render(); + + expect(screen.getByTestId('logo-destination')).toHaveTextContent(''); + }); + }); }); diff --git a/src/header/CatalogHeader.tsx b/src/header/CatalogHeader.tsx index 3a3eeb13..c5501a37 100644 --- a/src/header/CatalogHeader.tsx +++ b/src/header/CatalogHeader.tsx @@ -1,10 +1,12 @@ import Header from '@edx/frontend-component-header'; -import { getConfig } from '@edx/frontend-platform'; import { useMenuItems } from './hooks/useMenuItems'; +import { getLogoDestination } from './utils'; +import { AuthenticatedUserTypes } from './types'; const CatalogHeader = () => { const { + authenticatedUser, mainMenu, secondaryMenu, isNotHomePage, @@ -13,7 +15,7 @@ const CatalogHeader = () => { return (
); diff --git a/src/header/hooks/useMenuItems.ts b/src/header/hooks/useMenuItems.ts index 8c043fac..2f80ba4f 100644 --- a/src/header/hooks/useMenuItems.ts +++ b/src/header/hooks/useMenuItems.ts @@ -55,6 +55,7 @@ export const useMenuItems = () => { ]; return { + authenticatedUser, mainMenu: authenticatedUser ? getAuthenticatedUserMainMenu() : getNotAuthenticatedUserMainMenu(), secondaryMenu: getSecondaryMenu(), isNotHomePage, diff --git a/src/header/utils.ts b/src/header/utils.ts new file mode 100644 index 00000000..7daea3c2 --- /dev/null +++ b/src/header/utils.ts @@ -0,0 +1,21 @@ +import { getConfig } from '@edx/frontend-platform'; + +import { AuthenticatedUserTypes } from './types'; + +/** + * Determines the logo destination URL based on the current page and user authentication status. + * + * @param isNotHomePage - Whether the current page is not the home page + * @param authenticatedUser - The authenticated user object or null/undefined for non-authenticated users + * @returns The destination URL for the logo link, or undefined if on a non-home page + */ +export const getLogoDestination = (isNotHomePage: boolean, authenticatedUser: AuthenticatedUserTypes) => { + if (isNotHomePage) { + return undefined; + } + + if (authenticatedUser) { + return `/${process.env.APP_ID}/`; + } + return getConfig().LMS_BASE_URL; +}; From 3b8b761806e7874eb78a7d98de20417e009182c1 Mon Sep 17 00:00:00 2001 From: PKulkoRaccoonGang Date: Wed, 24 Sep 2025 16:45:38 +0300 Subject: [PATCH 05/10] refactor: after review --- .env | 1 + .env.development | 1 + src/header/utils.ts | 1 + 3 files changed, 3 insertions(+) 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/src/header/utils.ts b/src/header/utils.ts index 7daea3c2..c273ecae 100644 --- a/src/header/utils.ts +++ b/src/header/utils.ts @@ -17,5 +17,6 @@ export const getLogoDestination = (isNotHomePage: boolean, authenticatedUser: Au if (authenticatedUser) { return `/${process.env.APP_ID}/`; } + return getConfig().LMS_BASE_URL; }; From 5531de1abd0dd1bfcdcbf8e87215b89ea5c493ea Mon Sep 17 00:00:00 2001 From: PKulkoRaccoonGang Date: Thu, 25 Sep 2025 11:58:20 +0300 Subject: [PATCH 06/10] refactor: removed logo url logic --- src/header/CatalogHeader.test.tsx | 47 -------------------------- src/header/CatalogHeader.tsx | 10 +----- src/header/hooks/useMenuItems.test.tsx | 20 ----------- src/header/hooks/useMenuItems.ts | 4 --- src/header/utils.ts | 22 ------------ 5 files changed, 1 insertion(+), 102 deletions(-) delete mode 100644 src/header/utils.ts diff --git a/src/header/CatalogHeader.test.tsx b/src/header/CatalogHeader.test.tsx index 78289e7c..434a2819 100644 --- a/src/header/CatalogHeader.test.tsx +++ b/src/header/CatalogHeader.test.tsx @@ -46,7 +46,6 @@ describe('CatalogHeader', () => { content: messages.help.defaultMessage, }, ], - isNotHomePage: false, }; beforeEach(() => { @@ -61,7 +60,6 @@ describe('CatalogHeader', () => { expect(screen.getByTestId('header')).toBeInTheDocument(); expect(screen.getByTestId('main-menu')).toHaveTextContent(JSON.stringify(mockMenuItems.mainMenu)); expect(screen.getByTestId('secondary-menu')).toHaveTextContent(JSON.stringify(mockMenuItems.secondaryMenu)); - expect(screen.getByTestId('logo-destination')).toHaveTextContent(getConfig().LMS_BASE_URL); }); it('should display Help link if SUPPORT_URL is set', () => { @@ -157,7 +155,6 @@ describe('CatalogHeader', () => { const mockEmptyMenuItems = { mainMenu: [], secondaryMenu: [], - isNotHomePage: false, }; (useMenuItems as jest.Mock).mockReturnValue(mockEmptyMenuItems); @@ -227,7 +224,6 @@ describe('CatalogHeader', () => { content: messages.help.defaultMessage, }, ], - isNotHomePage: false, }; (useMenuItems as jest.Mock).mockReturnValue(mockMenuItemsForAuth); @@ -260,7 +256,6 @@ describe('CatalogHeader', () => { content: messages.help.defaultMessage, }, ], - isNotHomePage: false, }; (useMenuItems as jest.Mock).mockReturnValue(mockMenuItemsForNonAuth); @@ -274,46 +269,4 @@ describe('CatalogHeader', () => { expect(mainMenu[0].content).toBe(messages.exploreCourses.defaultMessage); expect(mainMenu[0].isActive).toBe(true); }); - - describe('logoDestination logic', () => { - it('should set logoDestination to LMS_BASE_URL for non-authenticated user on home page', () => { - const mockMenuItemsForNonAuth = { - ...mockMenuItems, - authenticatedUser: null, - isNotHomePage: false, - }; - (useMenuItems as jest.Mock).mockReturnValue(mockMenuItemsForNonAuth); - - render(); - - expect(screen.getByTestId('logo-destination')).toHaveTextContent(getConfig().LMS_BASE_URL); - }); - - it('should set logoDestination to /catalog/ for authenticated user on home page', () => { - const authenticatedUser = { username: 'testuser' }; - const mockMenuItemsForAuth = { - ...mockMenuItems, - authenticatedUser, - isNotHomePage: false, - }; - (useMenuItems as jest.Mock).mockReturnValue(mockMenuItemsForAuth); - - render(); - - expect(screen.getByTestId('logo-destination')).toHaveTextContent(`/${process.env.APP_ID}/`); - }); - - it('should set logoDestination to undefined for any user on non-home page', () => { - const mockMenuItemsForNonHomePage = { - ...mockMenuItems, - authenticatedUser: { username: 'testuser' }, - isNotHomePage: true, - }; - (useMenuItems as jest.Mock).mockReturnValue(mockMenuItemsForNonHomePage); - - render(); - - expect(screen.getByTestId('logo-destination')).toHaveTextContent(''); - }); - }); }); diff --git a/src/header/CatalogHeader.tsx b/src/header/CatalogHeader.tsx index c5501a37..126c8072 100644 --- a/src/header/CatalogHeader.tsx +++ b/src/header/CatalogHeader.tsx @@ -1,21 +1,13 @@ import Header from '@edx/frontend-component-header'; import { useMenuItems } from './hooks/useMenuItems'; -import { getLogoDestination } from './utils'; -import { AuthenticatedUserTypes } from './types'; const CatalogHeader = () => { - const { - authenticatedUser, - mainMenu, - secondaryMenu, - isNotHomePage, - } = useMenuItems(); + const { mainMenu, secondaryMenu } = useMenuItems(); return (
); diff --git a/src/header/hooks/useMenuItems.test.tsx b/src/header/hooks/useMenuItems.test.tsx index 3a53f9ee..9b3b3e42 100644 --- a/src/header/hooks/useMenuItems.test.tsx +++ b/src/header/hooks/useMenuItems.test.tsx @@ -146,24 +146,4 @@ describe('useMenuItems', () => { expect(result.current.secondaryMenu).toHaveLength(0); }); - - it('should set isNotHomePage to true when on course catalog page', () => { - (useLocation as jest.Mock).mockReturnValue({ - pathname: ROUTES.COURSES, - }); - - const { result } = renderWithAppContext(null); - - expect(result.current.isNotHomePage).toBe(true); - }); - - it('should set isNotHomePage to true when on course about page', () => { - (useLocation as jest.Mock).mockReturnValue({ - pathname: `${ROUTES.COURSE_ABOUT}/some-course`, - }); - - const { result } = renderWithAppContext(null); - - expect(result.current.isNotHomePage).toBe(true); - }); }); diff --git a/src/header/hooks/useMenuItems.ts b/src/header/hooks/useMenuItems.ts index 2f80ba4f..55b8875e 100644 --- a/src/header/hooks/useMenuItems.ts +++ b/src/header/hooks/useMenuItems.ts @@ -15,8 +15,6 @@ export const useMenuItems = () => { const { authenticatedUser } = useContext(AppContext) as AppContextTypes; const isCourseCatalogPage = location.pathname === ROUTES.COURSES; - const isCourseAboutPage = location.pathname.includes(ROUTES.COURSE_ABOUT); - const isNotHomePage = isCourseCatalogPage || isCourseAboutPage; const getNotAuthenticatedUserMainMenu = (): MenuItem[] => [ ...(getConfig().ENABLE_COURSE_DISCOVERY ? [{ @@ -55,9 +53,7 @@ export const useMenuItems = () => { ]; return { - authenticatedUser, mainMenu: authenticatedUser ? getAuthenticatedUserMainMenu() : getNotAuthenticatedUserMainMenu(), secondaryMenu: getSecondaryMenu(), - isNotHomePage, }; }; diff --git a/src/header/utils.ts b/src/header/utils.ts deleted file mode 100644 index c273ecae..00000000 --- a/src/header/utils.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { getConfig } from '@edx/frontend-platform'; - -import { AuthenticatedUserTypes } from './types'; - -/** - * Determines the logo destination URL based on the current page and user authentication status. - * - * @param isNotHomePage - Whether the current page is not the home page - * @param authenticatedUser - The authenticated user object or null/undefined for non-authenticated users - * @returns The destination URL for the logo link, or undefined if on a non-home page - */ -export const getLogoDestination = (isNotHomePage: boolean, authenticatedUser: AuthenticatedUserTypes) => { - if (isNotHomePage) { - return undefined; - } - - if (authenticatedUser) { - return `/${process.env.APP_ID}/`; - } - - return getConfig().LMS_BASE_URL; -}; From 3ae71367764e9abecc592f6009366a2a8ae25da4 Mon Sep 17 00:00:00 2001 From: PKulkoRaccoonGang Date: Fri, 26 Sep 2025 11:43:28 +0300 Subject: [PATCH 07/10] refactor: after review --- src/header/CatalogHeader.test.tsx | 196 +++++++----------------------- src/header/messages.ts | 8 +- 2 files changed, 51 insertions(+), 153 deletions(-) diff --git a/src/header/CatalogHeader.test.tsx b/src/header/CatalogHeader.test.tsx index 434a2819..fa8845a2 100644 --- a/src/header/CatalogHeader.test.tsx +++ b/src/header/CatalogHeader.test.tsx @@ -1,6 +1,6 @@ -import { getConfig, mergeConfig } from '@edx/frontend-platform'; +import { getConfig } from '@edx/frontend-platform'; -import { render, screen } from '../setupTest'; +import { render } from '../setupTest'; import { ROUTES } from '../routes'; import CatalogHeader from './CatalogHeader'; import { useMenuItems } from './hooks/useMenuItems'; @@ -11,19 +11,11 @@ jest.mock('@edx/frontend-platform', () => ({ 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(), -})); - -jest.mock('@edx/frontend-component-header', () => ({ - __esModule: true, - default: jest.fn(({ mainMenuItems, logoDestination, secondaryMenuItems }) => ( -
-
{JSON.stringify(mainMenuItems)}
-
{logoDestination}
-
{JSON.stringify(secondaryMenuItems)}
-
- )), + ensureConfig: jest.fn(), + subscribe: jest.fn(), })); jest.mock('./hooks/useMenuItems', () => ({ @@ -54,152 +46,68 @@ describe('CatalogHeader', () => { afterEach(() => jest.clearAllMocks()); - it('renders header with correct props', () => { + it('renders header component', () => { render(); - expect(screen.getByTestId('header')).toBeInTheDocument(); - expect(screen.getByTestId('main-menu')).toHaveTextContent(JSON.stringify(mockMenuItems.mainMenu)); - expect(screen.getByTestId('secondary-menu')).toHaveTextContent(JSON.stringify(mockMenuItems.secondaryMenu)); + expect(document.body).toBeInTheDocument(); }); - it('should display Help link if SUPPORT_URL is set', () => { - mergeConfig({ SUPPORT_URL: getConfig().SUPPORT_URL }); + it('calls useMenuItems hook', () => { render(); - const secondaryMenuText = screen.getByTestId('secondary-menu').textContent; - const secondaryMenu = secondaryMenuText ? JSON.parse(secondaryMenuText) : []; - expect(secondaryMenu).toHaveLength(1); - expect(secondaryMenu[0].href).toBe(getConfig().SUPPORT_URL); + expect(useMenuItems).toHaveBeenCalled(); }); - it('should display Programs link if it is enabled by configuration', () => { - const mockMenuItemsWithPrograms = { - ...mockMenuItems, - mainMenu: [ - ...mockMenuItems.mainMenu, - { - type: 'item', - href: `${getConfig().LMS_BASE_URL}/programs`, - content: messages.programs.defaultMessage, - }, - ], - }; - (useMenuItems as jest.Mock).mockReturnValue(mockMenuItemsWithPrograms); + it('passes correct props to Header component', () => { + const { mainMenu, secondaryMenu } = mockMenuItems; render(); - const mainMenuText = screen.getByTestId('main-menu').textContent; - const mainMenu = mainMenuText ? JSON.parse(mainMenuText) : []; - expect(mainMenu).toContainEqual( - expect.objectContaining({ - href: expect.stringContaining('/programs'), - }), - ); + expect(useMenuItems).toHaveBeenCalled(); + const hookResult = (useMenuItems as jest.Mock).mock.results[0].value; + expect(hookResult.mainMenu).toEqual(mainMenu); + expect(hookResult.secondaryMenu).toEqual(secondaryMenu); }); - it('should not display Discover New tab if course discovery is disabled', () => { - const mockMenuItemsWithoutDiscovery = { - ...mockMenuItems, - mainMenu: mockMenuItems.mainMenu.filter(item => !item.href.includes(ROUTES.COURSES)), - }; - (useMenuItems as jest.Mock).mockReturnValue(mockMenuItemsWithoutDiscovery); - - render(); - const mainMenuText = screen.getByTestId('main-menu').textContent; - const mainMenu = mainMenuText ? JSON.parse(mainMenuText) : []; - - expect(mainMenu).not.toContainEqual( - expect.objectContaining({ - href: expect.stringContaining(ROUTES.COURSES), - }), - ); - }); - - it('should not display Help link if SUPPORT_URL is not set', () => { - mergeConfig({ SUPPORT_URL: undefined }); - const mockMenuItemsWithoutHelp = { - ...mockMenuItems, - secondaryMenu: [], - }; - (useMenuItems as jest.Mock).mockReturnValue(mockMenuItemsWithoutHelp); - - render(); - const secondaryMenuText = screen.getByTestId('secondary-menu').textContent; - const secondaryMenu = secondaryMenuText ? JSON.parse(secondaryMenuText) : []; - - expect(secondaryMenu).toHaveLength(0); - }); - - it('should display active state for current page menu item', () => { - const mockMenuItemsWithActive = { - ...mockMenuItems, + it('handles different menu configurations', () => { + const mockMenuItemsWithPrograms = { mainMenu: [ + ...mockMenuItems.mainMenu, { type: 'item', - href: `${getConfig().LMS_BASE_URL}/dashboard`, - content: messages.courses.defaultMessage, - isActive: true, + href: `${getConfig().LMS_BASE_URL}/programs`, + content: messages.programs.defaultMessage, }, ], + secondaryMenu: mockMenuItems.secondaryMenu, }; - (useMenuItems as jest.Mock).mockReturnValue(mockMenuItemsWithActive); + + (useMenuItems as jest.Mock).mockReturnValue(mockMenuItemsWithPrograms); render(); - const mainMenuText = screen.getByTestId('main-menu').textContent; - const mainMenu = mainMenuText ? JSON.parse(mainMenuText) : []; - expect(mainMenu[0].isActive).toBe(true); + 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('should handle empty menu items gracefully', () => { + it('handles empty menu items', () => { const mockEmptyMenuItems = { mainMenu: [], secondaryMenu: [], }; - (useMenuItems as jest.Mock).mockReturnValue(mockEmptyMenuItems); - render(); - const mainMenuText = screen.getByTestId('main-menu').textContent; - const secondaryMenuText = screen.getByTestId('secondary-menu').textContent; - const mainMenu = mainMenuText ? JSON.parse(mainMenuText) : []; - const secondaryMenu = secondaryMenuText ? JSON.parse(secondaryMenuText) : []; - - expect(mainMenu).toHaveLength(0); - expect(secondaryMenu).toHaveLength(0); - }); - - it('should display Explore courses link when course discovery is enabled', () => { - const mockMenuItemsWithExploreCourses = { - ...mockMenuItems, - mainMenu: [ - ...mockMenuItems.mainMenu, - { - type: 'item', - href: `${getConfig().LMS_BASE_URL}${ROUTES.COURSES}`, - content: messages.exploreCourses.defaultMessage, - isActive: false, - }, - ], - }; - (useMenuItems as jest.Mock).mockReturnValue(mockMenuItemsWithExploreCourses); - mergeConfig({ ENABLE_COURSE_DISCOVERY: true }); + (useMenuItems as jest.Mock).mockReturnValue(mockEmptyMenuItems); render(); - const mainMenuText = screen.getByTestId('main-menu').textContent; - const mainMenu = mainMenuText ? JSON.parse(mainMenuText) : []; - expect(mainMenu).toContainEqual( - expect.objectContaining({ - href: expect.stringContaining(ROUTES.COURSES), - content: messages.exploreCourses.defaultMessage, - }), - ); + const hookResult = (useMenuItems as jest.Mock).mock.results[0].value; + expect(hookResult.mainMenu).toHaveLength(0); + expect(hookResult.secondaryMenu).toHaveLength(0); }); - it('should display correct menu items for authenticated user', () => { - const authenticatedUser = { username: 'testuser' }; - const mockMenuItemsForAuth = { - authenticatedUser, + it('handles authenticated user menu', () => { + const mockAuthMenuItems = { mainMenu: [ { type: 'item', @@ -211,11 +119,6 @@ describe('CatalogHeader', () => { href: `${getConfig().LMS_BASE_URL}/programs`, content: messages.programs.defaultMessage, }, - { - type: 'item', - href: `${getConfig().LMS_BASE_URL}${ROUTES.COURSES}`, - content: messages.discoverNew.defaultMessage, - }, ], secondaryMenu: [ { @@ -225,22 +128,19 @@ describe('CatalogHeader', () => { }, ], }; - (useMenuItems as jest.Mock).mockReturnValue(mockMenuItemsForAuth); - render(); + (useMenuItems as jest.Mock).mockReturnValue(mockAuthMenuItems); - const mainMenuText = screen.getByTestId('main-menu').textContent; - const mainMenu = mainMenuText ? JSON.parse(mainMenuText) : []; + render(); - expect(mainMenu).toHaveLength(3); - expect(mainMenu[0].href).toBe(`${getConfig().LMS_BASE_URL}/dashboard`); - expect(mainMenu[1].href).toBe(`${getConfig().LMS_BASE_URL}/programs`); - expect(mainMenu[2].href).toBe(`${getConfig().LMS_BASE_URL}${ROUTES.COURSES}`); + 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('should display correct menu items for non-authenticated user', () => { - const mockMenuItemsForNonAuth = { - authenticatedUser: null, + it('handles non-authenticated user menu', () => { + const mockNonAuthMenuItems = { mainMenu: [ { type: 'item', @@ -257,16 +157,14 @@ describe('CatalogHeader', () => { }, ], }; - (useMenuItems as jest.Mock).mockReturnValue(mockMenuItemsForNonAuth); - render(); + (useMenuItems as jest.Mock).mockReturnValue(mockNonAuthMenuItems); - const mainMenuText = screen.getByTestId('main-menu').textContent; - const mainMenu = mainMenuText ? JSON.parse(mainMenuText) : []; + render(); - expect(mainMenu).toHaveLength(1); - expect(mainMenu[0].href).toBe(`${getConfig().LMS_BASE_URL}${ROUTES.COURSES}`); - expect(mainMenu[0].content).toBe(messages.exploreCourses.defaultMessage); - expect(mainMenu[0].isActive).toBe(true); + 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/messages.ts b/src/header/messages.ts index 0a316a57..bb4ca0ae 100644 --- a/src/header/messages.ts +++ b/src/header/messages.ts @@ -14,22 +14,22 @@ const messages = defineMessages({ courses: { id: 'category.header.course', defaultMessage: 'Courses', - description: 'Header link for switching to Dashboard page.', + description: 'The text for the link to the Courses page.', }, exploreCourses: { id: 'category.header.explore-courses', defaultMessage: 'Explore courses', - description: 'Header link for switching to Dashboard page.', + description: 'The text for the link to the Explore courses page.', }, programs: { id: 'category.header.programs', defaultMessage: 'Programs', - description: 'Header link for switching to Programs page.', + description: 'The text for the link to the Programs page.', }, discoverNew: { id: 'category.header.discoverNew', defaultMessage: 'Discover new', - description: 'Header link for switching to Course Catalog page.', + description: 'The text for the link to the Discover new page.', }, }); From 50e892596234149d0011a7e15e3901fa825dc792 Mon Sep 17 00:00:00 2001 From: PKulkoRaccoonGang Date: Mon, 29 Sep 2025 11:24:34 +0300 Subject: [PATCH 08/10] refactor: corrected header tests --- src/header/CatalogHeader.test.tsx | 28 +++++++++++++++++++++------- 1 file changed, 21 insertions(+), 7 deletions(-) diff --git a/src/header/CatalogHeader.test.tsx b/src/header/CatalogHeader.test.tsx index fa8845a2..41c825d7 100644 --- a/src/header/CatalogHeader.test.tsx +++ b/src/header/CatalogHeader.test.tsx @@ -1,6 +1,6 @@ import { getConfig } from '@edx/frontend-platform'; -import { render } from '../setupTest'; +import { render, screen } from '../setupTest'; import { ROUTES } from '../routes'; import CatalogHeader from './CatalogHeader'; import { useMenuItems } from './hooks/useMenuItems'; @@ -22,6 +22,12 @@ 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: [ @@ -42,20 +48,28 @@ describe('CatalogHeader', () => { beforeEach(() => { (useMenuItems as jest.Mock).mockReturnValue(mockMenuItems); + jest.clearAllMocks(); }); - afterEach(() => jest.clearAllMocks()); - - it('renders header component', () => { + it('renders header component with correct props', () => { render(); - expect(document.body).toBeInTheDocument(); + 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('calls useMenuItems hook', () => { + it('passes correct main menu items to Header', () => { render(); - expect(useMenuItems).toHaveBeenCalled(); + 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', () => { From afa6c70886b0b37bcc5b24e4c8a53f2a5ab40b39 Mon Sep 17 00:00:00 2001 From: PKulkoRaccoonGang Date: Fri, 3 Oct 2025 11:15:15 +0300 Subject: [PATCH 09/10] refactor: corrected tests --- src/header/hooks/useMenuItems.test.tsx | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/src/header/hooks/useMenuItems.test.tsx b/src/header/hooks/useMenuItems.test.tsx index 9b3b3e42..a2b6e913 100644 --- a/src/header/hooks/useMenuItems.test.tsx +++ b/src/header/hooks/useMenuItems.test.tsx @@ -108,10 +108,15 @@ describe('useMenuItems', () => { const { result } = renderWithAppContext(null); - expect(result.current.mainMenu).toHaveLength(0); + expect(result.current.mainMenu).not.toContainEqual( + expect.objectContaining({ + type: 'item', + content: messages.discoverNew.defaultMessage, + }), + ); }); - it('should set isActive to true when on course catalog page', () => { + it('should set isActive to true when navigated to the corresponding route', () => { (getConfig as jest.Mock).mockReturnValue(DEFAULT_CONFIG); (useLocation as jest.Mock).mockReturnValue({ @@ -127,7 +132,7 @@ describe('useMenuItems', () => { const { result } = renderWithAppContext(null); expect(result.current.secondaryMenu).toHaveLength(1); - expect(result.current.secondaryMenu[0]).toEqual({ + expect(result.current.secondaryMenu).toContainEqual({ type: 'item', href: getConfig().SUPPORT_URL, content: messages.help.defaultMessage, @@ -144,6 +149,11 @@ describe('useMenuItems', () => { const { result } = renderWithAppContext(null); - expect(result.current.secondaryMenu).toHaveLength(0); + expect(result.current.secondaryMenu).not.toContainEqual( + expect.objectContaining({ + type: 'item', + content: messages.help.defaultMessage, + }), + ); }); }); From 95ec79766c6c6a25ddffe519a57a604950ce4f5d Mon Sep 17 00:00:00 2001 From: PKulkoRaccoonGang Date: Fri, 3 Oct 2025 16:39:00 +0300 Subject: [PATCH 10/10] refactor: after rebase --- .env.test | 1 + 1 file changed, 1 insertion(+) 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'