diff --git a/src/Header.jsx b/src/Header.jsx index bff801fe0..38f1307f8 100644 --- a/src/Header.jsx +++ b/src/Header.jsx @@ -28,6 +28,7 @@ ensureConfig([ subscribe(APP_CONFIG_INITIALIZED, () => { mergeConfig({ AUTHN_MINIMAL_HEADER: !!process.env.AUTHN_MINIMAL_HEADER, + ENABLE_ORG_LOGO: !!process.env.ENABLE_ORG_LOGO, }, 'Header additional config'); }); diff --git a/src/index.scss b/src/index.scss index 94114bd44..17a2c2e7c 100644 --- a/src/index.scss +++ b/src/index.scss @@ -43,9 +43,9 @@ $white: #fff; .user-dropdown { .btn { height: 3rem; - // @media (max-width: -1 + map-get($grid-breakpoints, "sm")) { - // padding: 0 0.5rem; - // } + @media (map-get($grid-breakpoints, "sm")) { + padding: 0 0.5rem; + } } } } diff --git a/src/learning-header/LearningHeader.jsx b/src/learning-header/LearningHeader.jsx index 373001d19..779589fb2 100644 --- a/src/learning-header/LearningHeader.jsx +++ b/src/learning-header/LearningHeader.jsx @@ -1,4 +1,4 @@ -import React, { useContext } from 'react'; +import React, { useContext, useEffect, useState } from 'react'; import PropTypes from 'prop-types'; import { getConfig } from '@edx/frontend-platform'; import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; @@ -7,6 +7,7 @@ import { AppContext } from '@edx/frontend-platform/react'; import AnonymousUserMenu from './AnonymousUserMenu'; import AuthenticatedUserDropdown from './AuthenticatedUserDropdown'; import messages from './messages'; +import getCourseLogoOrg from './data/api'; const LinkedLogo = ({ href, @@ -24,11 +25,18 @@ LinkedLogo.propTypes = { src: PropTypes.string.isRequired, alt: PropTypes.string.isRequired, }; - const LearningHeader = ({ - courseOrg, courseNumber, courseTitle, intl, showUserDropdown, + courseOrg, courseTitle, intl, showUserDropdown, }) => { const { authenticatedUser } = useContext(AppContext); + const [logoOrg, setLogoOrg] = useState(null); + const enableOrgLogo = getConfig().ENABLE_ORG_LOGO || false; + + useEffect(() => { + if (courseOrg) { + getCourseLogoOrg().then((logoOrgUrl) => { setLogoOrg(logoOrgUrl); }); + } + }); const headerLogo = ( {intl.formatMessage(messages.skipNavLink)}
{headerLogo} -
- {courseOrg} {courseNumber} - {courseTitle} +
+
+ {enableOrgLogo ? ( + (courseOrg && logoOrg) + && {`${courseOrg} + ) : null} + + {courseTitle} + +
{showUserDropdown && authenticatedUser && ( - + )} {showUserDropdown && !authenticatedUser && ( - + )}
@@ -63,7 +81,6 @@ const LearningHeader = ({ LearningHeader.propTypes = { courseOrg: PropTypes.string, - courseNumber: PropTypes.string, courseTitle: PropTypes.string, intl: intlShape.isRequired, showUserDropdown: PropTypes.bool, @@ -71,7 +88,6 @@ LearningHeader.propTypes = { LearningHeader.defaultProps = { courseOrg: null, - courseNumber: null, courseTitle: null, showUserDropdown: true, }; diff --git a/src/learning-header/LearningHeader.test.jsx b/src/learning-header/LearningHeader.test.jsx index 3d80888ef..b9b75c06a 100644 --- a/src/learning-header/LearningHeader.test.jsx +++ b/src/learning-header/LearningHeader.test.jsx @@ -1,9 +1,13 @@ import React from 'react'; import { - authenticatedUser, initializeMockApp, render, screen, + authenticatedUser, initializeMockApp, render, screen, waitFor, } from '../setupTest'; import { LearningHeader as Header } from '../index'; +jest.mock('./data/api', () => ({ + getCourseLogoOrg: jest.fn().mockResolvedValue(Promise.resolve('logo-url')), +})); + describe('Header', () => { beforeAll(async () => { // We need to mock AuthService to implicitly use `getAuthenticatedUser` within `AppContext.Provider`. @@ -18,12 +22,15 @@ describe('Header', () => { it('displays course data', () => { const courseData = { courseOrg: 'course-org', - courseNumber: 'course-number', courseTitle: 'course-title', }; render(
); - - expect(screen.getByText(`${courseData.courseOrg} ${courseData.courseNumber}`)).toBeInTheDocument(); - expect(screen.getByText(courseData.courseTitle)).toBeInTheDocument(); + waitFor( + () => { + expect(screen.getByAltText(`${courseData.courseOrg} logo`)).toHaveAttribute('src', 'logo-url'); + expect(screen.getByText(`${courseData.courseOrg}`)).toBeInTheDocument(); + expect(screen.getByText(courseData.courseTitle)).toBeInTheDocument(); + }, + ); }); }); diff --git a/src/learning-header/_header.scss b/src/learning-header/_header.scss new file mode 100644 index 000000000..357cc6b3a --- /dev/null +++ b/src/learning-header/_header.scss @@ -0,0 +1,10 @@ +@import "../../node_modules/bootstrap/scss/bootstrap-grid"; +@import "../../node_modules/bootstrap/scss/mixins/breakpoints"; + +.logo { + img { + @include media-breakpoint-down(sm) { + max-width: 85% !important; + } + } +} \ No newline at end of file diff --git a/src/learning-header/data/api.js b/src/learning-header/data/api.js new file mode 100644 index 000000000..41adec453 --- /dev/null +++ b/src/learning-header/data/api.js @@ -0,0 +1,22 @@ +import { getConfig } from '@edx/frontend-platform'; +import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; + +const getCourseLogoOrg = async () => { + try { + const orgId = window.location.pathname.match(/course-(.*?):([^+]+)/)[2]; + const { data } = await getAuthenticatedHttpClient() + .get( + `${getConfig().LMS_BASE_URL}/api/organizations/v0/organizations/${orgId}/`, + { useCache: true }, + ); + return data.logo; + } catch (error) { + const { httpErrorStatus } = error && error.customAttributes; + if (httpErrorStatus === 404) { + return null; + } + throw error; + } +}; + +export default getCourseLogoOrg; diff --git a/src/learning-header/data/api.test.js b/src/learning-header/data/api.test.js new file mode 100644 index 000000000..9bf629ca6 --- /dev/null +++ b/src/learning-header/data/api.test.js @@ -0,0 +1,59 @@ +import { getConfig } from '@edx/frontend-platform'; +import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; +import getCourseLogoOrg from './api'; +import { initializeMockApp } from '../../setupTest'; + +jest.mock('@edx/frontend-platform/auth'); + +class CustomError extends Error { + constructor(httpErrorStatus) { + super(); + this.customAttributes = { + httpErrorStatus, + }; + } +} + +describe('getCourseLogoOrg', () => { + beforeEach(async () => { + // We need to mock AuthService to implicitly use `getAuthenticatedHttpClient` within `AppContext.Provider`. + await initializeMockApp(); + delete window.location; + getAuthenticatedHttpClient.mockReset(); + }); + + it('should return the organization logo when the URL is valid', async () => { + window.location = new URL(`${getConfig().BASE_URL}/learning/course/course-v1:edX+DemoX+Demo_Course/home`); + getAuthenticatedHttpClient.mockReturnValue({ + get: async () => Promise.resolve({ + data: { + logo: 'https://example.com/logo.svg', + }, + }), + }); + const logoOrg = await getCourseLogoOrg(); + expect(logoOrg).toBe('https://example.com/logo.svg'); + }); + + it('should return null when the organization logo is not found', async () => { + window.location = new URL(`${getConfig().BASE_URL}/learning/course/course-v1:edX+DemoX+Nonexistent_Course/home`); + getAuthenticatedHttpClient.mockReturnValue({ + get: async () => { + throw new CustomError(404); + }, + }); + const logoOrg = await getCourseLogoOrg(); + expect(logoOrg).toBeNull(); + }); + + it('should throw an error when an unexpected error occurs', async () => { + const customError = new CustomError(500); + window.location = new URL(`${getConfig().BASE_URL}/learning/course/course-v1:edX+DemoX+Demo_Course/home`); + getAuthenticatedHttpClient.mockReturnValue({ + get: async () => { + throw customError; + }, + }); + await expect(getCourseLogoOrg()).rejects.toThrow(customError); + }); +});