diff --git a/.env b/.env index 694cecd..414e0df 100644 --- a/.env +++ b/.env @@ -7,6 +7,7 @@ ECOMMERCE_BASE_URL='' LEARNING_BASE_URL='' LANGUAGE_PREFERENCE_COOKIE_NAME='' LMS_BASE_URL='' +STUDIO_BASE_URL='' LOGIN_URL='' LOGOUT_URL='' LOGO_URL='' diff --git a/.env.development b/.env.development index 29a9133..439c5ab 100644 --- a/.env.development +++ b/.env.development @@ -8,6 +8,7 @@ ECOMMERCE_BASE_URL='http://localhost:18130' LEARNING_BASE_URL='http://localhost:2000' LANGUAGE_PREFERENCE_COOKIE_NAME='openedx-language-preference' LMS_BASE_URL='http://localhost:18000' +STUDIO_BASE_URL='http://localhost:18001' LOGIN_URL='http://localhost:18000/login' LOGOUT_URL='http://localhost:18000/logout' LOGO_URL=https://edx-cdn.org/v3/default/logo.svg diff --git a/.env.test b/.env.test index f866f36..ce5c9d7 100644 --- a/.env.test +++ b/.env.test @@ -6,6 +6,7 @@ ECOMMERCE_BASE_URL='http://localhost:18130' LEARNING_BASE_URL='http://localhost:2000' LANGUAGE_PREFERENCE_COOKIE_NAME='openedx-language-preference' LMS_BASE_URL='http://localhost:18000' +STUDIO_BASE_URL='http://localhost:18001' LOGIN_URL='http://localhost:18000/login' LOGOUT_URL='http://localhost:18000/logout' LOGO_URL=https://edx-cdn.org/v3/default/logo.svg diff --git a/src/course-about/CourseAboutPage.test.tsx b/src/course-about/CourseAboutPage.test.tsx index 9337893..7ae392f 100644 --- a/src/course-about/CourseAboutPage.test.tsx +++ b/src/course-about/CourseAboutPage.test.tsx @@ -1,5 +1,6 @@ import { useLocation } from 'react-router-dom'; import { useMediaQuery } from '@openedx/paragon'; +import { getConfig } from '@edx/frontend-platform'; import genericMessages from '../generic/video-modal/messages'; import { @@ -13,6 +14,7 @@ import courseMediaMessages from './course-intro/course-media/messages'; import sidebarDetailsMessages from './course-sidebar/sidebar-details/messages'; import sidebarSocialMessages from './course-sidebar/sidebar-social/messages'; import { ROUTES } from '../routes'; +import courseAboutMessages from './messages'; const mockGetAuthenticatedUser = jest.fn(); @@ -20,6 +22,10 @@ jest.mock('@edx/frontend-platform/auth', () => ({ getAuthenticatedUser: () => mockGetAuthenticatedUser(), })); +jest.mock('@edx/frontend-platform', () => ({ + getConfig: jest.fn(), +})); + jest.mock('./data/api', () => ({ fetchCourseAboutData: jest.fn(), })); @@ -34,7 +40,7 @@ jest.mock('@openedx/paragon', () => ({ })); const mockUseMediaQuery = useMediaQuery as jest.Mock; - +const mockGetConfig = getConfig as jest.Mock; const mockFetchCourseAboutData = fetchCourseAboutData as jest.Mock; const mockUseLocation = useLocation as jest.Mock; @@ -46,6 +52,10 @@ describe('CourseAboutPage Integration Tests', () => { }); mockGetAuthenticatedUser.mockReturnValue(null); mockUseMediaQuery.mockReturnValue(false); + mockGetConfig.mockReturnValue({ + LMS_BASE_URL: process.env.LMS_BASE_URL, + STUDIO_BASE_URL: process.env.STUDIO_BASE_URL, + }); }); it('should show loading state when data is being fetched', async () => { @@ -412,4 +422,126 @@ describe('CourseAboutPage Integration Tests', () => { }); }); }); + + describe('Course overview', () => { + it('should render course overview with content', async () => { + const courseOverviewText = 'Course overview content'; + const courseData = { + ...mockCourseAboutResponse, + overview: `

${courseOverviewText}

`, + }; + + mockFetchCourseAboutData.mockReturnValue(courseData); + render(); + + await waitFor(() => { + expect(screen.getByText(courseOverviewText)).toBeInTheDocument(); + }); + }); + + it('should not render course overview for non-staff user when overview is empty', async () => { + const courseData = { + ...mockCourseAboutResponse, + overview: '', + }; + + mockFetchCourseAboutData.mockReturnValue(courseData); + render(); + + await waitFor(() => { + expect(screen.queryByRole('link', { + name: courseAboutMessages.viewAboutPageInStudio.defaultMessage, + })).not.toBeInTheDocument(); + }); + }); + + it('should process overview content to replace image paths', async () => { + const courseData = { + ...mockCourseAboutResponse, + overview: 'Test Image', + }; + + mockFetchCourseAboutData.mockReturnValue(courseData); + render(); + + await waitFor(() => { + const img = screen.getByAltText('Test Image'); + expect(img).toHaveAttribute('src', `${getConfig().LMS_BASE_URL}/static/images/test.jpg`); + }); + }); + + it('should process overview content with asset paths', async () => { + const courseData = { + ...mockCourseAboutResponse, + overview: 'Test Asset', + }; + + mockFetchCourseAboutData.mockReturnValue(courseData); + render(); + + await waitFor(() => { + const img = screen.getByAltText('Test Asset'); + expect(img).toHaveAttribute('src', `${getConfig().LMS_BASE_URL}/asset/test.jpg`); + }); + }); + + it('should show Studio button for global staff user', async () => { + mockGetAuthenticatedUser.mockReturnValue({ administrator: true }); + + const courseData = { + ...mockCourseAboutResponse, + overview: '

Course overview content

', + }; + + mockFetchCourseAboutData.mockReturnValue(courseData); + render(); + + await waitFor(() => { + const studioButton = screen.getByRole('link', { + name: courseAboutMessages.viewAboutPageInStudio.defaultMessage, + }); + expect(studioButton).toBeInTheDocument(); + expect(studioButton).toHaveAttribute( + 'href', + expect.stringContaining(`${getConfig().STUDIO_BASE_URL}/settings/details/`), + ); + }); + }); + + it('should hide Studio button for non-staff user', async () => { + mockGetAuthenticatedUser.mockReturnValue(null); + + const courseData = { + ...mockCourseAboutResponse, + overview: '

Course overview content

', + }; + + mockFetchCourseAboutData.mockReturnValue(courseData); + render(); + + await waitFor(() => { + expect(screen.queryByRole('link', { + name: courseAboutMessages.viewAboutPageInStudio.defaultMessage, + })).not.toBeInTheDocument(); + }); + }); + + it('should hide Studio button for authenticated user without administrator role', async () => { + mockGetAuthenticatedUser.mockReturnValue({ username: 'testuser', administrator: false }); + + const courseData = { + ...mockCourseAboutResponse, + overview: '

Course overview content

', + }; + + mockFetchCourseAboutData.mockReturnValue(courseData); + render(); + + await waitFor(() => { + expect(screen.queryByRole('link', { + name: courseAboutMessages.viewAboutPageInStudio.defaultMessage, + })).not.toBeInTheDocument(); + }); + }); + }); }); diff --git a/src/course-about/CourseAboutPage.tsx b/src/course-about/CourseAboutPage.tsx index 8e10218..c4c010e 100644 --- a/src/course-about/CourseAboutPage.tsx +++ b/src/course-about/CourseAboutPage.tsx @@ -9,9 +9,9 @@ import { useIntl } from '@edx/frontend-platform/i18n'; import { Loading } from '@src/generic'; import CourseAboutIntroSlot from '@src/plugin-slots/CourseAboutIntroSlot'; import CourseAboutCourseMediaSlot from '@src/plugin-slots/CourseAboutCourseMediaSlot'; +import CourseAboutOverviewSlot from '@src/plugin-slots/CourseAboutOverviewSlot'; import { useCourseAboutData } from './data/hooks'; import CourseSidebar from './course-sidebar/CourseSidebar'; -import { CourseOverview } from './course-overview'; import messages from './messages'; import { GRID_LAYOUT } from './layout'; @@ -53,7 +53,10 @@ const CourseAboutPage = () => { - + @@ -61,7 +64,10 @@ const CourseAboutPage = () => { ) : ( - + )} diff --git a/src/course-about/course-overview/CourseOverview.test.tsx b/src/course-about/course-overview/CourseOverview.test.tsx new file mode 100644 index 0000000..87ded06 --- /dev/null +++ b/src/course-about/course-overview/CourseOverview.test.tsx @@ -0,0 +1,117 @@ +import { getAuthenticatedUser } from '@edx/frontend-platform/auth'; +import { getConfig } from '@edx/frontend-platform'; + +import { render, screen } from '@src/setupTest'; +import messages from '../messages'; +import { CourseOverview } from '.'; + +jest.mock('@edx/frontend-platform/auth', () => ({ + getAuthenticatedUser: jest.fn(), +})); + +jest.mock('@edx/frontend-platform', () => ({ + getAuthenticatedUser: jest.fn(() => ({ username: 'test-user', roles: [] })), + getConfig: jest.fn(), +})); + +const mockGetAuthenticatedUser = getAuthenticatedUser as jest.Mock; +const mockGetConfig = getConfig as jest.Mock; + +const mockCourseId = 'course-v1:TestX+Test101+2023'; + +describe('CourseOverview', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockGetConfig.mockReturnValue({ + LMS_BASE_URL: process.env.LMS_BASE_URL, + STUDIO_BASE_URL: process.env.STUDIO_BASE_URL, + }); + mockGetAuthenticatedUser.mockReturnValue(null); + }); + + describe('Content rendering', () => { + it('renders overview content when provided', () => { + const overviewText = 'Course overview content'; + const overviewData = `

${overviewText}

`; + + render(); + expect(screen.getByText(overviewText)).toBeInTheDocument(); + }); + + it('renders nothing for non-staff users', () => { + const { container } = render(); + + expect(container.firstChild).toBeNull(); + }); + + it('renders Studio button for global staff users', () => { + mockGetAuthenticatedUser.mockReturnValue({ administrator: true }); + + render(); + + const studioButton = screen.getByRole('link', { + name: messages.viewAboutPageInStudio.defaultMessage, + }); + + expect(studioButton).toBeInTheDocument(); + expect(studioButton).toHaveAttribute( + 'href', + `${getConfig().STUDIO_BASE_URL}/settings/details/${mockCourseId}`, + ); + }); + + it('processes overview content to replace image paths', () => { + const overviewData = 'Test'; + render(); + + const img = screen.getByAltText('Test'); + expect(img).toHaveAttribute('src', `${getConfig().LMS_BASE_URL}/static/images/test.jpg`); + }); + + it('processes overview content with asset paths', () => { + const overviewData = 'Test'; + render(); + + const img = screen.getByAltText('Test'); + expect(img).toHaveAttribute('src', `${getConfig().LMS_BASE_URL}/asset/test.jpg`); + }); + }); + + describe('Global staff features', () => { + it('shows Studio button for global staff user', () => { + mockGetAuthenticatedUser.mockReturnValue({ administrator: true }); + render(); + + const studioButton = screen.getByRole('link', { + name: messages.viewAboutPageInStudio.defaultMessage, + }); + expect(studioButton).toBeInTheDocument(); + expect(studioButton).toHaveAttribute( + 'href', + `${getConfig().STUDIO_BASE_URL}/settings/details/${mockCourseId}`, + ); + }); + + it('hides Studio button for non-staff user', () => { + mockGetAuthenticatedUser.mockReturnValue(null); + render(); + + expect( + screen.queryByRole('link', { + name: messages.viewAboutPageInStudio.defaultMessage, + }), + ).not.toBeInTheDocument(); + }); + + it('hides Studio button for authenticated user without administrator role', () => { + mockGetAuthenticatedUser.mockReturnValue({ username: 'testuser', administrator: false }); + render(); + + expect( + screen.queryByRole('link', { + name: messages.viewAboutPageInStudio.defaultMessage, + }), + ).not.toBeInTheDocument(); + }); + }); +}); diff --git a/src/course-about/course-overview/index.tsx b/src/course-about/course-overview/index.tsx index 5fbbbeb..dc5ea37 100644 --- a/src/course-about/course-overview/index.tsx +++ b/src/course-about/course-overview/index.tsx @@ -1,15 +1,70 @@ -// TODO: Temporary placeholder for the course overview -export const CourseOverview = () => ( -
-

About This Course

-

- Lorem Ipsum is simply dummy text of the printing and typesetting industry. - Lorem Ipsum has been the industry's standard dummy text ever since the 1500s, - when an unknown printer took a galley of type and scrambled it to make a type specimen book. - It has survived not only five centuries, but also the leap into electronic typesetting, - remaining essentially unchanged. It was popularised in the 1960s with the release - of Letraset sheets containing Lorem Ipsum passages, and more recently with desktop - publishing software like Aldus PageMaker including versions of Lorem Ipsum. -

-
-); +import { + Button, Container, useMediaQuery, breakpoints, Card, ActionRow, +} from '@openedx/paragon'; +import { getConfig } from '@edx/frontend-platform'; +import { useIntl } from '@edx/frontend-platform/i18n'; +import { getAuthenticatedUser } from '@edx/frontend-platform/auth'; + +import messages from '../messages'; +import type { CourseOverviewProps } from './types'; +import { processOverviewContent } from './utils'; + +export const CourseOverview = ({ overviewData, courseId }: CourseOverviewProps) => { + const intl = useIntl(); + const authenticatedUser = getAuthenticatedUser(); + const isGlobalStaff = authenticatedUser?.administrator || false; + const isExtraSmall = useMediaQuery({ maxWidth: breakpoints.extraSmall.maxWidth }); + + const processedOverviewData = processOverviewContent(overviewData, getConfig().LMS_BASE_URL); + const hasOverviewContent = processedOverviewData.trim().length > 0; + + if (!hasOverviewContent) { + if (!isGlobalStaff) { + return null; + } + + return ( + + + + ); + } + + return ( + + + {isGlobalStaff && ( + + + + )} + /> + )} + + { + /* eslint-disable-next-line react/no-danger */ +
+ } + + + + ); +}; diff --git a/src/course-about/course-overview/types.ts b/src/course-about/course-overview/types.ts new file mode 100644 index 0000000..f5c277f --- /dev/null +++ b/src/course-about/course-overview/types.ts @@ -0,0 +1,4 @@ +export interface CourseOverviewProps { + overviewData: string; + courseId: string; +} diff --git a/src/course-about/course-overview/utils.ts b/src/course-about/course-overview/utils.ts new file mode 100644 index 0000000..497f57b --- /dev/null +++ b/src/course-about/course-overview/utils.ts @@ -0,0 +1,15 @@ +/** + * Processes overview content to replace image paths + * @param overview - The overview HTML content + * @returns Processed overview content with updated image paths + */ +export const processOverviewContent = (overview: string, lmsBaseUrl: string): string => { + if (!overview) { + return overview; + } + + return overview.replace( + /src="\/(static\/images\/|asset)/g, + (_, path) => `src="${lmsBaseUrl}/${path}`, + ); +}; diff --git a/src/course-about/messages.ts b/src/course-about/messages.ts index aff0839..0f7664a 100644 --- a/src/course-about/messages.ts +++ b/src/course-about/messages.ts @@ -6,6 +6,11 @@ const messages = defineMessages({ defaultMessage: 'If you experience repeated failures, please email support at {supportEmail}', description: 'Error page message.', }, + viewAboutPageInStudio: { + id: 'catalog.course-about.view-about-page-in-studio', + defaultMessage: 'View About Page in Studio', + description: 'Link to view the Schedule and Details page in Studio.', + }, }); export default messages; diff --git a/src/plugin-slots/CourseAboutOverviewSlot/README.md b/src/plugin-slots/CourseAboutOverviewSlot/README.md new file mode 100644 index 0000000..c3e8afb --- /dev/null +++ b/src/plugin-slots/CourseAboutOverviewSlot/README.md @@ -0,0 +1,119 @@ +# Course about overview slot + +### Slot ID: `org.openedx.frontend.catalog.course_about_page.overview` + +## Description + +This slot is used to replace/modify/hide the entire course about overview section on the Course about page. + +### Plugin Props: + +* `overviewData` - Object. HTML content of the course overview section. +* `courseId` - String. The unique identifier of the course. + +## Examples + +### Default content + +![Course overview slot with default content](./images/screenshot_default.png) + +### Replaced with custom component + +![🦶 in Course About page overview slot](./images/screenshot_custom.png) + +The following `env.config.tsx` will replace the Course About page overview slot entirely (in this case with a centered `h1` tag) + +```tsx +import { DIRECT_PLUGIN, PLUGIN_OPERATIONS } from '@openedx/frontend-plugin-framework'; + +const config = { + pluginSlots: { + 'org.openedx.frontend.catalog.course_about_page.overview': { + keepDefault: false, + plugins: [ + { + op: PLUGIN_OPERATIONS.Insert, + widget: { + id: 'custom_about_page_overview_component', + type: DIRECT_PLUGIN, + RenderWidget: () => ( +

🦶

+ ), + }, + }, + ] + } + }, +} + +export default config; +``` + +### Custom component with plugin props + +**Default overview section:** +![Course overview slot with default content](./images/screenshot_default_with_overview_contant.png) + +**Custom button component:** +![Custom button component in Course About page overview slot](./images/screenshot_custom_button.png) + +**Modal dialog with course overview content:** +![Modal dialog displaying course overview content](./images/screenshot_custom_modal.png) + +The following `env.config.tsx` example demonstrates how to replace the Course About page overview slot with a custom component that uses the plugin props (`overviewData` and `courseId`). In this case, it creates a modal dialog that displays the course overview content when a button is clicked. + +```tsx +import { DIRECT_PLUGIN, PLUGIN_OPERATIONS } from '@openedx/frontend-plugin-framework'; +import { useToggle, Button, ModalDialog } from '@openedx/paragon'; + +const CourseOverviewModal = ({ overviewData, courseId }) => { + const [isOpen, open, close] = useToggle(false); + + return ( + <> + + + + + {courseId} + + + +
+ + + + ) +} + +const config = { + pluginSlots: { + 'org.openedx.frontend.catalog.course_about_page.overview': { + keepDefault: false, + plugins: [ + { + op: PLUGIN_OPERATIONS.Insert, + widget: { + id: 'custom_about_page_overview_component', + type: DIRECT_PLUGIN, + RenderWidget: ({ overviewData, courseId }) => ( + + ), + }, + }, + ] + } + }, +} + +export default config; +``` diff --git a/src/plugin-slots/CourseAboutOverviewSlot/images/screenshot_custom.png b/src/plugin-slots/CourseAboutOverviewSlot/images/screenshot_custom.png new file mode 100644 index 0000000..8f30717 Binary files /dev/null and b/src/plugin-slots/CourseAboutOverviewSlot/images/screenshot_custom.png differ diff --git a/src/plugin-slots/CourseAboutOverviewSlot/images/screenshot_custom_button.png b/src/plugin-slots/CourseAboutOverviewSlot/images/screenshot_custom_button.png new file mode 100644 index 0000000..8fac217 Binary files /dev/null and b/src/plugin-slots/CourseAboutOverviewSlot/images/screenshot_custom_button.png differ diff --git a/src/plugin-slots/CourseAboutOverviewSlot/images/screenshot_custom_modal.png b/src/plugin-slots/CourseAboutOverviewSlot/images/screenshot_custom_modal.png new file mode 100644 index 0000000..8df69d9 Binary files /dev/null and b/src/plugin-slots/CourseAboutOverviewSlot/images/screenshot_custom_modal.png differ diff --git a/src/plugin-slots/CourseAboutOverviewSlot/images/screenshot_default.png b/src/plugin-slots/CourseAboutOverviewSlot/images/screenshot_default.png new file mode 100644 index 0000000..5dc7f87 Binary files /dev/null and b/src/plugin-slots/CourseAboutOverviewSlot/images/screenshot_default.png differ diff --git a/src/plugin-slots/CourseAboutOverviewSlot/images/screenshot_default_with_overview_contant.png b/src/plugin-slots/CourseAboutOverviewSlot/images/screenshot_default_with_overview_contant.png new file mode 100644 index 0000000..29e1d5a Binary files /dev/null and b/src/plugin-slots/CourseAboutOverviewSlot/images/screenshot_default_with_overview_contant.png differ diff --git a/src/plugin-slots/CourseAboutOverviewSlot/index.tsx b/src/plugin-slots/CourseAboutOverviewSlot/index.tsx new file mode 100644 index 0000000..b0eda83 --- /dev/null +++ b/src/plugin-slots/CourseAboutOverviewSlot/index.tsx @@ -0,0 +1,18 @@ +import { PluginSlot } from '@openedx/frontend-plugin-framework'; + +import { CourseOverview } from '@src/course-about/course-overview'; +import type { CourseOverviewProps } from '@src/course-about/course-overview/types'; + +const CourseAboutOverviewSlot = ({ overviewData, courseId }: CourseOverviewProps) => ( + + + +); + +export default CourseAboutOverviewSlot;