diff --git a/src/components/academies/GoToAcademy.jsx b/src/components/academies/GoToAcademy.jsx new file mode 100644 index 0000000000..58337c65eb --- /dev/null +++ b/src/components/academies/GoToAcademy.jsx @@ -0,0 +1,36 @@ +import { Link } from 'react-router-dom'; +import { Button } from '@openedx/paragon'; +import { FormattedMessage } from '@edx/frontend-platform/i18n'; +import React from 'react'; +import useEnterpriseCustomer from '../app/data/hooks/useEnterpriseCustomer'; +import { useAcademies } from '../app/data'; + +const GoToAcademy = () => { + const { data: academies } = useAcademies(); + const { data: enterpriseCustomer } = useEnterpriseCustomer(); + + return ( + <> +

+ +

+ + + ); +}; + +export default GoToAcademy; diff --git a/src/components/app/data/hooks/useRecommendCoursesForMe.js b/src/components/app/data/hooks/useRecommendCoursesForMe.js index 07b29f3503..b530a495da 100644 --- a/src/components/app/data/hooks/useRecommendCoursesForMe.js +++ b/src/components/app/data/hooks/useRecommendCoursesForMe.js @@ -2,6 +2,7 @@ import { useMatch } from 'react-router-dom'; import useContentHighlightsConfiguration from './useContentHighlightsConfiguration'; import useIsAssignmentsOnlyLearner from './useIsAssignmentsOnlyLearner'; +import useEnterpriseCustomer from './useEnterpriseCustomer'; /** * Keeps track of whether the enterprise banner should include the "Recommend courses for me" button. @@ -12,9 +13,10 @@ export default function useRecommendCoursesForMe() { const { data: contentHighlightsConfiguration } = useContentHighlightsConfiguration(); const canOnlyViewHighlightSets = !!contentHighlightsConfiguration?.canOnlyViewHighlightSets; const isAssignmentsOnlyLearner = useIsAssignmentsOnlyLearner(); - // If user is not on the search page route, or users are restricted to only viewing highlight sets, - // the "Recommend courses for me" button should not be shown. - if (!isSearchPage || canOnlyViewHighlightSets) { + const { data: enterpriseCustomer } = useEnterpriseCustomer(); + // If user is not on the search page route, or users are restricted to only viewing highlight sets or + // if the enterprise customer has enabled one academy, the "Recommend courses for me" button should not be shown. + if (!isSearchPage || canOnlyViewHighlightSets || enterpriseCustomer.enableOneAcademy) { return { shouldRecommendCourses: false, }; diff --git a/src/components/app/data/hooks/useRecommendCoursesForMe.test.jsx b/src/components/app/data/hooks/useRecommendCoursesForMe.test.jsx index 5ee279578b..c4128be8c9 100644 --- a/src/components/app/data/hooks/useRecommendCoursesForMe.test.jsx +++ b/src/components/app/data/hooks/useRecommendCoursesForMe.test.jsx @@ -5,7 +5,8 @@ import { AppContext } from '@edx/frontend-platform/react'; import useRecommendCoursesForMe from './useRecommendCoursesForMe'; import useContentHighlightsConfiguration from './useContentHighlightsConfiguration'; import useIsAssignmentsOnlyLearner from './useIsAssignmentsOnlyLearner'; -import { authenticatedUserFactory } from '../services/data/__factories__'; +import useEnterpriseCustomer from './useEnterpriseCustomer'; +import { authenticatedUserFactory, enterpriseCustomerFactory } from '../services/data/__factories__'; jest.mock('react-router-dom', () => ({ ...jest.requireActual('react-router-dom'), @@ -18,8 +19,10 @@ jest.mock('./useContentHighlightsConfiguration', () => jest.fn().mockReturnValue }, })); jest.mock('./useIsAssignmentsOnlyLearner', () => jest.fn().mockReturnValue(false)); +jest.mock('./useEnterpriseCustomer', () => jest.fn()); const mockAuthenticatedUser = authenticatedUserFactory(); +const mockEnterpriseCustomer = enterpriseCustomerFactory(); const wrapper = ({ children }) => ( @@ -30,6 +33,7 @@ const wrapper = ({ children }) => ( describe('useRecommendCoursesForMe', () => { beforeEach(() => { jest.clearAllMocks(); + useEnterpriseCustomer.mockReturnValue({ data: mockEnterpriseCustomer }); }); it('should not show recommend course CTA by default', () => { @@ -44,24 +48,35 @@ describe('useRecommendCoursesForMe', () => { mockRouteMatch: null, // simulates non-search page route canOnlyViewHighlightSets: false, isAssignmentsOnlyLearner: false, + enableOneAcademy: false, hasRecommendCourseCTA: false, }, { mockRouteMatch: { path: '/search' }, // simulates search page route canOnlyViewHighlightSets: false, isAssignmentsOnlyLearner: false, + enableOneAcademy: false, hasRecommendCourseCTA: true, }, { mockRouteMatch: { path: '/search' }, // simulates search page route canOnlyViewHighlightSets: true, isAssignmentsOnlyLearner: false, + enableOneAcademy: false, + hasRecommendCourseCTA: false, + }, + { + mockRouteMatch: { path: '/search' }, // simulates search page route + canOnlyViewHighlightSets: false, + isAssignmentsOnlyLearner: false, + enableOneAcademy: true, hasRecommendCourseCTA: false, }, ])('should support showing recommend course CTA, when appropriate (%s)', async ({ mockRouteMatch, canOnlyViewHighlightSets, isAssignmentsOnlyLearner, + enableOneAcademy, hasRecommendCourseCTA, }) => { useMatch.mockReturnValue(mockRouteMatch); @@ -71,6 +86,7 @@ describe('useRecommendCoursesForMe', () => { }, }); useIsAssignmentsOnlyLearner.mockReturnValue(isAssignmentsOnlyLearner); + useEnterpriseCustomer.mockReturnValue({ data: { ...mockEnterpriseCustomer, enableOneAcademy } }); const { result } = renderHook(() => useRecommendCoursesForMe(), { wrapper }); diff --git a/src/components/app/data/services/data/__factories__/academies.factory.js b/src/components/app/data/services/data/__factories__/academies.factory.js new file mode 100644 index 0000000000..fd4a27d702 --- /dev/null +++ b/src/components/app/data/services/data/__factories__/academies.factory.js @@ -0,0 +1,20 @@ +import { Factory } from 'rosie'; // eslint-disable-line import/no-extraneous-dependencies +import { faker } from '@faker-js/faker'; // eslint-disable-line import/no-extraneous-dependencies +import { camelCaseObject } from '@edx/frontend-platform'; +import { v4 as uuidv4 } from 'uuid'; + +Factory.define('academy') + .attr('uuid', uuidv4()) + .attr('title', faker.lorem.words()) + .attr('short_description', faker.lorem.sentence(50)) + .attr('long_description', faker.lorem.sentence(20)) + .attr('image', faker.image.urlPlaceholder()) + .attr('tags', []); + +export function academyFactory(overrides = {}) { + return camelCaseObject(Factory.build('academy', overrides)); +} + +export function academiesFactory(count = 1, overrides = {}) { + return Array.from({ length: count }, () => academyFactory(overrides)); +} diff --git a/src/components/app/data/services/data/__factories__/enterpriseCustomerUser.factory.js b/src/components/app/data/services/data/__factories__/enterpriseCustomerUser.factory.js index 90fe9257fe..c2f6c8f01d 100644 --- a/src/components/app/data/services/data/__factories__/enterpriseCustomerUser.factory.js +++ b/src/components/app/data/services/data/__factories__/enterpriseCustomerUser.factory.js @@ -28,6 +28,7 @@ Factory.define('enterpriseCustomer') .attr('enable_data_sharing_consent', true) .attr('admin_users', [{ email: faker.internet.email() }]) .attr('disable_search', false) + .attr('enable_one_academy', false) .attr('branding_configuration', { logo: faker.image.urlPlaceholder(), primary_color: faker.internet.color(), diff --git a/src/components/app/data/services/data/__factories__/index.js b/src/components/app/data/services/data/__factories__/index.js index 44562421a1..e8a3b5462f 100644 --- a/src/components/app/data/services/data/__factories__/index.js +++ b/src/components/app/data/services/data/__factories__/index.js @@ -1,3 +1,4 @@ import './enterpriseCustomerUser.factory'; export * from './enterpriseCustomerUser.factory'; +export * from './academies.factory'; diff --git a/src/components/dashboard/main-content/DashboardMainContent.test.jsx b/src/components/dashboard/main-content/DashboardMainContent.test.jsx index 9e513f240a..d3efef53fc 100644 --- a/src/components/dashboard/main-content/DashboardMainContent.test.jsx +++ b/src/components/dashboard/main-content/DashboardMainContent.test.jsx @@ -7,14 +7,24 @@ import { QueryClientProvider } from '@tanstack/react-query'; import DashboardMainContent from './DashboardMainContent'; import { queryClient, renderWithRouter } from '../../../utils/tests'; import { features } from '../../../config'; -import { useCanOnlyViewHighlights, useEnterpriseCourseEnrollments, useEnterpriseCustomer } from '../../app/data'; -import { authenticatedUserFactory, enterpriseCustomerFactory } from '../../app/data/services/data/__factories__'; +import { + useCanOnlyViewHighlights, + useEnterpriseCourseEnrollments, + useEnterpriseCustomer, + useAcademies, +} from '../../app/data'; +import { + authenticatedUserFactory, + enterpriseCustomerFactory, + academiesFactory, +} from '../../app/data/services/data/__factories__'; jest.mock('../../app/data', () => ({ ...jest.requireActual('../../app/data'), useEnterpriseCustomer: jest.fn(), useCanOnlyViewHighlights: jest.fn(), useEnterpriseCourseEnrollments: jest.fn(), + useAcademies: jest.fn(), })); jest.mock('../../../config', () => ({ @@ -41,6 +51,7 @@ describe('DashboardMainContent', () => { beforeEach(() => { jest.clearAllMocks(); useEnterpriseCustomer.mockReturnValue({ data: mockEnterpriseCustomer }); + useAcademies.mockReturnValue({ data: academiesFactory(3) }); useCanOnlyViewHighlights.mockReturnValue({ data: false }); useEnterpriseCourseEnrollments.mockReturnValue({ data: { diff --git a/src/components/dashboard/main-content/course-enrollments/CourseEnrollmentsEmptyState.jsx b/src/components/dashboard/main-content/course-enrollments/CourseEnrollmentsEmptyState.jsx index e0b0eeceb1..498450effb 100644 --- a/src/components/dashboard/main-content/course-enrollments/CourseEnrollmentsEmptyState.jsx +++ b/src/components/dashboard/main-content/course-enrollments/CourseEnrollmentsEmptyState.jsx @@ -4,13 +4,16 @@ import { Link } from 'react-router-dom'; import { useEnterpriseCustomer, - useCanOnlyViewHighlights, + useCanOnlyViewHighlights, useAcademies, } from '../../../app/data'; import CourseRecommendations from '../CourseRecommendations'; +import GoToAcademy from '../../../academies/GoToAcademy'; const CourseEnrollmentsEmptyState = () => { const { data: enterpriseCustomer } = useEnterpriseCustomer(); const { data: canOnlyViewHighlightSets } = useCanOnlyViewHighlights(); + const { data: academies } = useAcademies(); + if (enterpriseCustomer.disableSearch) { return (

@@ -25,6 +28,11 @@ const CourseEnrollmentsEmptyState = () => {

); } + + if (enterpriseCustomer.enableOneAcademy && academies?.length === 1) { + return ; + } + return ( <>

@@ -45,7 +53,6 @@ const CourseEnrollmentsEmptyState = () => { description="Label for Find a course button on enterprise dashboard's courses tab." /> -
{canOnlyViewHighlightSets === false && } diff --git a/src/components/dashboard/tests/DashboardPage.test.jsx b/src/components/dashboard/tests/DashboardPage.test.jsx index 67cddbf52f..9c31c70af1 100644 --- a/src/components/dashboard/tests/DashboardPage.test.jsx +++ b/src/components/dashboard/tests/DashboardPage.test.jsx @@ -25,6 +25,7 @@ import { useCouponCodes, useEnterpriseCourseEnrollments, useEnterpriseCustomer, + useAcademies, useEnterpriseOffers, useEnterprisePathwaysList, useEnterpriseProgramsList, @@ -33,7 +34,11 @@ import { useSubscriptions, useHasAvailableSubsidiesOrRequests, } from '../../app/data'; -import { authenticatedUserFactory, enterpriseCustomerFactory } from '../../app/data/services/data/__factories__'; +import { + authenticatedUserFactory, + enterpriseCustomerFactory, + academiesFactory, +} from '../../app/data/services/data/__factories__'; const dummyProgramData = { uuid: 'test-uuid', @@ -98,6 +103,7 @@ jest.mock('../../app/data', () => ({ useBrowseAndRequest: jest.fn(), useIsAssignmentsOnlyLearner: jest.fn(), useHasAvailableSubsidiesOrRequests: jest.fn(), + useAcademies: jest.fn(), })); jest.mock('@edx/frontend-enterprise-utils', () => ({ @@ -200,6 +206,7 @@ describe('', () => { beforeEach(() => { jest.clearAllMocks(); useEnterpriseCustomer.mockReturnValue({ data: mockEnterpriseCustomer }); + useAcademies.mockReturnValue({ data: academiesFactory(3) }); useSubscriptions.mockReturnValue({ data: { subscriptionLicense: undefined, diff --git a/src/components/search/data/searchLoader.js b/src/components/search/data/searchLoader.js index 1fd7beabd4..8baa5def5c 100644 --- a/src/components/search/data/searchLoader.js +++ b/src/components/search/data/searchLoader.js @@ -1,5 +1,6 @@ +import { generatePath, redirect } from 'react-router-dom'; import { getConfig } from '@edx/frontend-platform/config'; -import { ensureAuthenticatedUser } from '../../app/routes/data'; +import { ensureAuthenticatedUser } from '../../app/routes/data/utils'; import { extractEnterpriseCustomer, queryAcademiesList, queryContentHighlightSets } from '../../app/data'; export default function makeSearchLoader(queryClient) { @@ -18,11 +19,10 @@ export default function makeSearchLoader(queryClient) { authenticatedUser, enterpriseSlug, }); - const searchData = [ - queryClient.ensureQueryData( - queryAcademiesList(enterpriseCustomer.uuid), - ), - ]; + + const academiesListQuery = queryAcademiesList(enterpriseCustomer.uuid); + + const searchData = [queryClient.ensureQueryData(academiesListQuery)]; if (getConfig().FEATURE_CONTENT_HIGHLIGHTS) { searchData.push( queryClient.ensureQueryData( @@ -33,6 +33,15 @@ export default function makeSearchLoader(queryClient) { await Promise.all(searchData); + const academies = queryClient.getQueryData(academiesListQuery.queryKey); + if (enterpriseCustomer.enableOneAcademy && academies.length === 1) { + const redirectPath = generatePath('/:enterpriseSlug/academies/:academyUUID', { + enterpriseSlug, + academyUUID: academies[0].uuid, + }); + return redirect(redirectPath); + } + return null; }; } diff --git a/src/components/search/data/searchLoader.test.jsx b/src/components/search/data/searchLoader.test.jsx index 89504fb738..860cf45e98 100644 --- a/src/components/search/data/searchLoader.test.jsx +++ b/src/components/search/data/searchLoader.test.jsx @@ -1,7 +1,8 @@ -import { screen } from '@testing-library/react'; +import { screen, waitFor } from '@testing-library/react'; import '@testing-library/jest-dom/extend-expect'; import { getConfig } from '@edx/frontend-platform/config'; +import { when } from 'jest-when'; import { renderWithRouterProvider } from '../../../utils/tests'; import makeSearchLoader from './searchLoader'; import { @@ -9,11 +10,11 @@ import { queryAcademiesList, queryContentHighlightSets, } from '../../app/data'; -import { ensureAuthenticatedUser } from '../../app/routes/data'; +import { ensureAuthenticatedUser } from '../../app/routes/data/utils'; import { enterpriseCustomerFactory } from '../../app/data/services/data/__factories__'; -jest.mock('../../app/routes/data', () => ({ - ...jest.requireActual('../../app/routes/data'), +jest.mock('../../app/routes/data/utils', () => ({ + ...jest.requireActual('../../app/routes/data/utils'), ensureAuthenticatedUser: jest.fn(), })); jest.mock('../../app/data', () => ({ @@ -37,8 +38,15 @@ jest.mock('@edx/frontend-platform/config', () => ({ const mockEnterpriseCustomer = enterpriseCustomerFactory(); extractEnterpriseCustomer.mockResolvedValue(mockEnterpriseCustomer); +const mockAcademies = [ + { + uuid: 'test-academy-uuid-1', + }, +]; + const mockQueryClient = { ensureQueryData: jest.fn().mockResolvedValue({}), + getQueryData: jest.fn().mockReturnValue(mockAcademies), }; describe('searchLoader', () => { @@ -94,6 +102,7 @@ describe('searchLoader', () => { }), ); }); + it('ensures the requisite search data is resolved with content highlights', async () => { getConfig.mockReturnValue({ FEATURE_CONTENT_HIGHLIGHTS: true, @@ -124,4 +133,65 @@ describe('searchLoader', () => { }), ); }); + + it('Redirect learners whose enterprise has enabled one academy.', async () => { + extractEnterpriseCustomer.mockResolvedValue(enterpriseCustomerFactory({ enable_one_academy: true })); + const academiesQuery = queryAcademiesList(mockEnterpriseCustomer.uuid); + + when(mockQueryClient.ensureQueryData).calledWith( + expect.objectContaining({ + queryKey: academiesQuery.queryKey, + }), + ).mockResolvedValue(mockAcademies); + ensureAuthenticatedUser.mockResolvedValue({ userId: 3, username: 'test-user' }); + + renderWithRouterProvider({ + path: '/:enterpriseSlug/search', + element:

, + loader: makeSearchLoader(mockQueryClient), + }, { + routes: [ + { + path: '/:enterpriseCustomer/academies/:academyUUID', + element:
, + }, + ], + initialEntries: [`/${mockEnterpriseCustomer.slug}/search`], + }); + + // Validate user is redirected to the academy details page. + await waitFor(() => { + expect(screen.getByTestId('academy-details-page')).toBeInTheDocument(); + }); + }); + + it('Does not redirect the learners if enterprise one academy is not enabled.', async () => { + extractEnterpriseCustomer.mockResolvedValue(mockEnterpriseCustomer); + const academiesQuery = queryAcademiesList(mockEnterpriseCustomer.uuid); + when(mockQueryClient.ensureQueryData).calledWith( + expect.objectContaining({ + queryKey: academiesQuery.queryKey, + }), + ).mockResolvedValue(mockAcademies); + ensureAuthenticatedUser.mockResolvedValue({ userId: 3, username: 'test-user' }); + + renderWithRouterProvider({ + path: '/:enterpriseSlug/search', + element:
, + loader: makeSearchLoader(mockQueryClient), + }, { + routes: [ + { + path: '/:enterpriseCustomer/academies/:academyUUID', + element:
, + }, + ], + initialEntries: [`/${mockEnterpriseCustomer.slug}/search`], + }); + + // Validate user is not redirected to the academy details page. + await waitFor(() => { + expect(screen.getByTestId('search-page')).toBeInTheDocument(); + }); + }); }); diff --git a/src/components/site-header/SiteHeaderNavMenu.jsx b/src/components/site-header/SiteHeaderNavMenu.jsx index 94d265c5e4..6815032be4 100644 --- a/src/components/site-header/SiteHeaderNavMenu.jsx +++ b/src/components/site-header/SiteHeaderNavMenu.jsx @@ -1,12 +1,14 @@ import { useIntl } from '@edx/frontend-platform/i18n'; import { NavLink } from 'react-router-dom'; import { useEnterpriseCustomer, useIsAssignmentsOnlyLearner } from '../app/data'; +import { useContentDiscoveryNavLink } from './data'; const SiteHeaderNavMenu = () => { const { data: enterpriseCustomer } = useEnterpriseCustomer(); const isAssignmentOnlyLearner = useIsAssignmentsOnlyLearner(); const intl = useIntl(); const mainMenuLinkClassName = 'nav-link'; + const contentDiscoveryNavLink = useContentDiscoveryNavLink(mainMenuLinkClassName); if (enterpriseCustomer.disableSearch) { return null; @@ -21,15 +23,7 @@ const SiteHeaderNavMenu = () => { description: 'Dashboard link title in site header navigation.', })} - {!isAssignmentOnlyLearner && ( - - {intl.formatMessage({ - id: 'site.header.nav.search.title', - defaultMessage: 'Find a Course', - description: 'Find a course link in site header navigation.', - })} - - )} + {!isAssignmentOnlyLearner && contentDiscoveryNavLink} ); }; diff --git a/src/components/site-header/data/hooks/index.js b/src/components/site-header/data/hooks/index.js new file mode 100644 index 0000000000..ca435a42bc --- /dev/null +++ b/src/components/site-header/data/hooks/index.js @@ -0,0 +1 @@ +export { default as useContentDiscoveryNavLink } from './useContentDiscoveryNavLink'; diff --git a/src/components/site-header/data/hooks/useContentDiscoveryNavLink.jsx b/src/components/site-header/data/hooks/useContentDiscoveryNavLink.jsx new file mode 100644 index 0000000000..16b8a250f9 --- /dev/null +++ b/src/components/site-header/data/hooks/useContentDiscoveryNavLink.jsx @@ -0,0 +1,28 @@ +import { NavLink } from 'react-router-dom'; +import { FormattedMessage } from '@edx/frontend-platform/i18n'; +import { useAcademies, useEnterpriseCustomer } from '../../../app/data'; + +export default function useContentDiscoveryNavLink(mainMenuLinkClassName) { + const { data: enterpriseCustomer } = useEnterpriseCustomer(); + const { data: academies } = useAcademies(); + if (enterpriseCustomer.enableOneAcademy && academies.length === 1) { + return ( + + + + ); + } + return ( + + + + ); +} diff --git a/src/components/site-header/data/index.js b/src/components/site-header/data/index.js new file mode 100644 index 0000000000..4cc90d02bd --- /dev/null +++ b/src/components/site-header/data/index.js @@ -0,0 +1 @@ +export * from './hooks'; diff --git a/src/components/site-header/tests/SiteHeader.test.jsx b/src/components/site-header/tests/SiteHeader.test.jsx index ecc61e788f..5b7de7385a 100644 --- a/src/components/site-header/tests/SiteHeader.test.jsx +++ b/src/components/site-header/tests/SiteHeader.test.jsx @@ -10,12 +10,17 @@ import { getConfig } from '@edx/frontend-platform/config'; import SiteHeader from '../SiteHeader'; import { renderWithRouter, renderWithRouterProvider } from '../../../utils/tests'; -import { useEnterpriseCustomer, useEnterpriseLearner } from '../../app/data'; -import { authenticatedUserFactory, enterpriseCustomerFactory } from '../../app/data/services/data/__factories__'; +import { useAcademies, useEnterpriseCustomer, useEnterpriseLearner } from '../../app/data'; +import { + academiesFactory, + authenticatedUserFactory, + enterpriseCustomerFactory, +} from '../../app/data/services/data/__factories__'; jest.mock('../../app/data', () => ({ ...jest.requireActual('../../app/data'), useEnterpriseLearner: jest.fn(), + useAcademies: jest.fn(), useEnterpriseCustomer: jest.fn(), useIsAssignmentsOnlyLearner: jest.fn(), })); @@ -88,6 +93,7 @@ describe('', () => { ], }, }); + useAcademies.mockReturnValue({ data: academiesFactory(3) }); }); test('renders link with logo to dashboard', () => {