diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..a4ac0be --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,9 @@ +{ + "permissions": { + "allow": [ + "Bash(pnpm prettier:*)", + "Bash(pnpm typecheck:*)", + "Bash(grep -rn \"config\" lib/config* config.*)" + ] + } +} diff --git a/app/course-content/[course_id]/__tests__/layout.test.tsx b/app/course-content/[course_id]/__tests__/layout.test.tsx index f5ba864..9769a12 100644 --- a/app/course-content/[course_id]/__tests__/layout.test.tsx +++ b/app/course-content/[course_id]/__tests__/layout.test.tsx @@ -1,156 +1,68 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { render, screen, fireEvent } from '@testing-library/react'; +import { render, screen } from '@testing-library/react'; import '@testing-library/jest-dom'; import React from 'react'; -// Mock next/link -vi.mock('next/link', () => ({ - default: ({ href, children, className }: any) => ( - - {children} - - ), -})); - -// Mock next/navigation +const mockRouterPush = vi.fn(); vi.mock('next/navigation', () => ({ - useSearchParams: vi.fn(() => new URLSearchParams()), -})); - -// Mock lodash -vi.mock('lodash', () => ({ - default: { - isEmpty: vi.fn( - (val: any) => - !val || Object.keys(val).length === 0 || (Array.isArray(val) && val.length === 0), - ), - }, + useRouter: () => ({ push: mockRouterPush }), })); -// Mock lucide-react icons -vi.mock('lucide-react', () => ({ - ChevronRight: () => >, - ListTree: () => ListTree, +vi.mock('sonner', () => ({ + toast: { error: vi.fn(), success: vi.fn() }, })); -// Mock helpers vi.mock('@/utils/helpers', () => ({ getTenant: vi.fn(() => 'test-tenant'), - getUserId: vi.fn(() => 'test-user-id'), })); -// Mock useGetDepartmentMemberCheckQuery -vi.mock('@/services/core', () => ({ - useGetDepartmentMemberCheckQuery: vi.fn(() => ({ - data: { is_platform_admin: false }, - })), +vi.mock('@/lib/config', () => ({ + config: { + urls: { dm: () => 'https://dm.test' }, + settings: { courseEligibilityEnabled: () => false }, + }, })); -// Mock useChatState const mockSetCourseMentor = vi.fn(); vi.mock('@/components/chat-button', () => ({ - useChatState: vi.fn(() => ({ - setCourseMentor: mockSetCourseMentor, - })), + useChatState: vi.fn(() => ({ setCourseMentor: mockSetCourseMentor })), })); -// Mock useCourseDetail -const mockHandleFetchCourseInfo = vi.fn(); -const mockHandleFetchCourseSyllabus = vi.fn(); -const mockHandleOpenLesson = vi.fn(); -const mockHandleFetchCourseProgress = vi.fn(); -const mockHandleFetchCourseCompletion = vi.fn(); - -vi.mock('@/hooks/courses/use-course-detail', () => ({ - useCourseDetail: vi.fn(() => ({ - handleFetchCourseInfo: mockHandleFetchCourseInfo, - handleFetchCourseSyllabus: mockHandleFetchCourseSyllabus, - handleOpenLesson: mockHandleOpenLesson, - handleFetchCourseProgress: mockHandleFetchCourseProgress, - handleFetchCourseCompletion: mockHandleFetchCourseCompletion, - course: null, - courseInfoLoadingState: 'successful', - courseOutline: null, - courseOutlineLoading: false, - courseCompletion: null, - courseGradingPolicyActive: false, - })), -})); - -// Mock useEdxIframe -vi.mock('@/hooks/courses/use-edx-iframe', () => ({ - useEdxIframe: vi.fn(() => ({ - getUnitToIframe: vi.fn(() => null), - getParentsInfosFromSublessonId: vi.fn(() => null), +vi.mock('@iblai/iblai-js/data-layer', () => ({ + useGetDepartmentMemberCheckQuery: vi.fn(() => ({ + data: { is_platform_admin: false }, })), })); -// Mock EdxIframeContext -vi.mock('@/hooks/courses/edx-iframe-context', () => ({ - EdxIframeContext: React.createContext({}), -})); - -// Mock CourseOutlineContext -vi.mock('@/contexts/course-outline-context', () => ({ - CourseOutlineContext: React.createContext({}), -})); - -// Mock CourseOutline -vi.mock('@/components/course-outline', () => ({ - CourseOutline: () =>
CourseOutline
, -})); - -// Mock CourseOutlineDrawer -vi.mock('@/components/course-outline-drawer', () => ({ - CourseOutlineDrawer: () =>
CourseOutlineDrawer
, -})); - -// Mock CourseAccessGuard — renders children unconditionally so layout tests are isolated -vi.mock('@/components/course-access-guard', () => ({ - CourseAccessGuard: ({ children }: any) => <>{children}, -})); - -// Mock ExamInfo from data-layer -vi.mock('@iblai/iblai-js/data-layer', () => ({ - ExamInfo: {}, +const capturedProps: { value?: any } = {}; +vi.mock('@iblai/iblai-js/web-containers/next', () => ({ + CourseContentLayout: (props: any) => { + capturedProps.value = props; + return ( +
+ {props.children} +
+ ); + }, })); -// Mock React.use vi.mock('react', async () => { const actual = await vi.importActual('react'); return { ...actual, - use: vi.fn((promise: any) => { - if (promise && typeof promise === 'object' && 'course_id' in promise) { - return promise; - } - return { course_id: 'course-v1:test+course+2024' }; - }), + use: vi.fn((value: any) => value), }; }); import CourseContentLayout from '../layout'; -import { useCourseDetail } from '@/hooks/courses/use-course-detail'; -import { useGetDepartmentMemberCheckQuery } from '@/services/core'; +import { useGetDepartmentMemberCheckQuery } from '@iblai/iblai-js/data-layer'; describe('CourseContentLayout', () => { - const defaultParams = Promise.resolve({ course_id: 'course-v1%3Atest%2Bcourse%2B2024' }); + const params = { course_id: 'course-v1%3Atest%2Bcourse%2B2024' } as any; beforeEach(() => { vi.clearAllMocks(); - vi.mocked(useCourseDetail).mockReturnValue({ - handleFetchCourseInfo: mockHandleFetchCourseInfo, - handleFetchCourseSyllabus: mockHandleFetchCourseSyllabus, - handleOpenLesson: mockHandleOpenLesson, - handleFetchCourseProgress: mockHandleFetchCourseProgress, - handleFetchCourseCompletion: mockHandleFetchCourseCompletion, - course: null, - courseInfoLoadingState: 'successful', - courseOutline: null, - courseOutlineLoading: false, - courseCompletion: null, - courseGradingPolicyActive: false, - } as any); + capturedProps.value = undefined; vi.mocked(useGetDepartmentMemberCheckQuery).mockReturnValue({ data: { is_platform_admin: false }, } as any); @@ -158,229 +70,129 @@ describe('CourseContentLayout', () => { it('renders without crashing', () => { const { container } = render( - +
children
, ); expect(container).toBeTruthy(); }); - it('renders CourseOutlineDrawer', () => { - render( - -
children
-
, - ); - expect(screen.getByTestId('course-outline-drawer')).toBeInTheDocument(); - }); - - it('renders CourseOutline in sidebar', () => { - render( - -
children
-
, - ); - expect(screen.getByTestId('course-outline')).toBeInTheDocument(); - }); - - it('renders course navigation tabs (Course, Progress, Dates, Discussion)', () => { - render( - -
children
-
, - ); - expect(screen.getByText('Course')).toBeInTheDocument(); - expect(screen.getByText('Progress')).toBeInTheDocument(); - expect(screen.getByText('Dates')).toBeInTheDocument(); - expect(screen.getByText('Discussion')).toBeInTheDocument(); - }); - - it('hides Instructor tab when user is not platform admin', () => { - vi.mocked(useGetDepartmentMemberCheckQuery).mockReturnValue({ - data: { is_platform_admin: false }, - } as any); - - render( - -
children
-
, - ); - expect(screen.queryByText('Instructor')).not.toBeInTheDocument(); - }); - - it('shows Instructor tab when user is platform admin', () => { - vi.mocked(useGetDepartmentMemberCheckQuery).mockReturnValue({ - data: { is_platform_admin: true }, - } as any); - + it('renders the shared SDK layout', () => { render( - +
children
, ); - expect(screen.getByText('Instructor')).toBeInTheDocument(); + expect(screen.getByTestId('shared-course-content-layout')).toBeInTheDocument(); }); - it('renders children within layout', () => { + it('renders children within the shared layout', () => { render( - +
Page Content
, ); expect(screen.getByTestId('page-content')).toBeInTheDocument(); }); - it('shows course display_name when course is loaded', () => { - vi.mocked(useCourseDetail).mockReturnValue({ - handleFetchCourseInfo: mockHandleFetchCourseInfo, - handleFetchCourseSyllabus: mockHandleFetchCourseSyllabus, - handleOpenLesson: mockHandleOpenLesson, - handleFetchCourseProgress: mockHandleFetchCourseProgress, - handleFetchCourseCompletion: mockHandleFetchCourseCompletion, - course: { display_name: 'My Test Course', mentor_hidden: false, mentor_uuid: 'uuid-123' }, - courseOutline: null, - courseOutlineLoading: false, - courseCompletion: null, - courseGradingPolicyActive: false, - } as any); - + it('decodes the course_id from params', () => { render( - +
children
, ); - - expect(screen.getAllByText('My Test Course').length).toBeGreaterThan(0); + expect(capturedProps.value?.courseId).toBe('course-v1:test+course+2024'); }); - it('shows completion percentage in progress bar', () => { - vi.mocked(useCourseDetail).mockReturnValue({ - handleFetchCourseInfo: mockHandleFetchCourseInfo, - handleFetchCourseSyllabus: mockHandleFetchCourseSyllabus, - handleOpenLesson: mockHandleOpenLesson, - handleFetchCourseProgress: mockHandleFetchCourseProgress, - handleFetchCourseCompletion: mockHandleFetchCourseCompletion, - course: null, - courseOutline: null, - courseOutlineLoading: false, - courseCompletion: { completion_percentage: 75, grading_percentage: 80 }, - courseGradingPolicyActive: false, - } as any); - + it('passes current tenant to shared layout', () => { render( - +
children
, ); - - expect(screen.getByText('75%')).toBeInTheDocument(); + expect(capturedProps.value?.currentTenant).toBe('test-tenant'); }); - it('shows grading percentage when courseGradingPolicyActive is true', () => { - vi.mocked(useCourseDetail).mockReturnValue({ - handleFetchCourseInfo: mockHandleFetchCourseInfo, - handleFetchCourseSyllabus: mockHandleFetchCourseSyllabus, - handleOpenLesson: mockHandleOpenLesson, - handleFetchCourseProgress: mockHandleFetchCourseProgress, - handleFetchCourseCompletion: mockHandleFetchCourseCompletion, - course: null, - courseOutline: null, - courseOutlineLoading: false, - courseCompletion: { completion_percentage: 50, grading_percentage: 90 }, - courseGradingPolicyActive: true, + it('passes isPlatformAdmin=false when user is not a platform admin', () => { + vi.mocked(useGetDepartmentMemberCheckQuery).mockReturnValue({ + data: { is_platform_admin: false }, } as any); - render( - +
children
, ); - - expect(screen.getByText('Grade:')).toBeInTheDocument(); - expect(screen.getByText('90%')).toBeInTheDocument(); + expect(capturedProps.value?.isPlatformAdmin).toBe(false); }); - it('hides Grade section when courseGradingPolicyActive is false', () => { - vi.mocked(useCourseDetail).mockReturnValue({ - handleFetchCourseInfo: mockHandleFetchCourseInfo, - handleFetchCourseSyllabus: mockHandleFetchCourseSyllabus, - handleOpenLesson: mockHandleOpenLesson, - handleFetchCourseProgress: mockHandleFetchCourseProgress, - handleFetchCourseCompletion: mockHandleFetchCourseCompletion, - course: null, - courseOutline: null, - courseOutlineLoading: false, - courseCompletion: { completion_percentage: 50, grading_percentage: 90 }, - courseGradingPolicyActive: false, + it('passes isPlatformAdmin=true when user is a platform admin', () => { + vi.mocked(useGetDepartmentMemberCheckQuery).mockReturnValue({ + data: { is_platform_admin: true }, } as any); - render( - +
children
, ); - - expect(screen.queryByText('Grade:')).not.toBeInTheDocument(); + expect(capturedProps.value?.isPlatformAdmin).toBe(true); }); - it('renders open course outline button', () => { + it('maps forum tab href to discussion', () => { render( - +
children
, ); - - const outlineButton = screen.getByLabelText('Open course outline'); - expect(outlineButton).toBeInTheDocument(); + const href = capturedProps.value?.tabHrefTemplate({ courseId: 'c1', tab: 'forum' }); + expect(href).toBe('/course-content/c1/discussion'); }); - it('clicking open course outline button opens drawer', () => { + it('keeps non-forum tabs as-is in href template', () => { render( - +
children
, ); - - const outlineButton = screen.getByLabelText('Open course outline'); - fireEvent.click(outlineButton); - // Verifies no error thrown (the state is internal) - expect(outlineButton).toBeInTheDocument(); + const href = capturedProps.value?.tabHrefTemplate({ courseId: 'c1', tab: 'progress' }); + expect(href).toBe('/course-content/c1/progress'); }); - it('calls handleFetchCourseInfo on mount', () => { + it('pushes to /error/403 on unauthorized', () => { render( - +
children
, ); - expect(mockHandleFetchCourseInfo).toHaveBeenCalled(); + capturedProps.value?.onUnauthorized(); + expect(mockRouterPush).toHaveBeenCalledWith('/error/403'); }); - it('calls handleFetchCourseProgress on mount', () => { + it('pushes to /error/404 on not found', () => { render( - +
children
, ); - expect(mockHandleFetchCourseProgress).toHaveBeenCalled(); + capturedProps.value?.onNotFound(); + expect(mockRouterPush).toHaveBeenCalledWith('/error/404'); }); - it('calls handleFetchCourseCompletion on mount', () => { + it('uses router.push for internal navigation', () => { render( - +
children
, ); - expect(mockHandleFetchCourseCompletion).toHaveBeenCalled(); + capturedProps.value?.onNavigate('/some/path'); + expect(mockRouterPush).toHaveBeenCalledWith('/some/path'); }); - it('shows 0% when courseCompletion is null', () => { + it('hooks onCourseMentorChange to setCourseMentor from chat state', () => { render( - +
children
, ); - - expect(screen.getByText('0%')).toBeInTheDocument(); + expect(capturedProps.value?.onCourseMentorChange).toBe(mockSetCourseMentor); }); }); diff --git a/app/course-content/[course_id]/__tests__/loading.test.tsx b/app/course-content/[course_id]/__tests__/loading.test.tsx index e7d6036..b1cb0e6 100644 --- a/app/course-content/[course_id]/__tests__/loading.test.tsx +++ b/app/course-content/[course_id]/__tests__/loading.test.tsx @@ -2,10 +2,8 @@ import { describe, it, expect, vi } from 'vitest'; import { render, screen } from '@testing-library/react'; import '@testing-library/jest-dom'; -vi.mock('@/components/ui/skeleton', () => ({ - Skeleton: ({ className }: { className?: string }) => ( -
- ), +vi.mock('@iblai/iblai-js/web-containers', () => ({ + CourseContentLoading: () =>
, })); import Loading from '../loading'; @@ -15,9 +13,8 @@ describe('Loading (course-content)', () => { render(); }); - it('renders skeleton elements', () => { + it('renders the shared CourseContentLoading component', () => { render(); - const skeletons = screen.getAllByTestId('skeleton'); - expect(skeletons.length).toBeGreaterThan(0); + expect(screen.getByTestId('course-content-loading')).toBeInTheDocument(); }); }); diff --git a/app/course-content/[course_id]/bookmarks/__tests__/page.test.tsx b/app/course-content/[course_id]/bookmarks/__tests__/page.test.tsx new file mode 100644 index 0000000..23a5097 --- /dev/null +++ b/app/course-content/[course_id]/bookmarks/__tests__/page.test.tsx @@ -0,0 +1,46 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import '@testing-library/jest-dom'; + +const capturedProps: { value?: any } = {}; +vi.mock('@iblai/iblai-js/web-containers/next', () => ({ + CourseContentTabPage: (props: any) => { + capturedProps.value = props; + return
; + }, +})); + +vi.mock('@/lib/config', () => ({ + config: { + urls: { + lms: () => 'https://lms.test', + mfe: () => 'https://mfe.test', + legacyLmsUrl: () => 'https://legacy-lms.test', + }, + }, +})); + +import BookmarksTab from '../page'; + +describe('BookmarksTab', () => { + beforeEach(() => { + capturedProps.value = undefined; + }); + + it('renders the shared CourseContentTabPage', () => { + render(); + expect(screen.getByTestId('course-content-tab-page')).toBeInTheDocument(); + }); + + it('passes tab="bookmarks" to the shared component', () => { + render(); + expect(capturedProps.value?.tab).toBe('bookmarks'); + }); + + it('passes lmsUrl, mfeUrl, and legacyLmsUrl from config', () => { + render(); + expect(capturedProps.value?.lmsUrl).toBe('https://lms.test'); + expect(capturedProps.value?.mfeUrl).toBe('https://mfe.test'); + expect(capturedProps.value?.legacyLmsUrl).toBe('https://legacy-lms.test'); + }); +}); diff --git a/app/course-content/[course_id]/bookmarks/page.tsx b/app/course-content/[course_id]/bookmarks/page.tsx index dbbd968..3a93663 100644 --- a/app/course-content/[course_id]/bookmarks/page.tsx +++ b/app/course-content/[course_id]/bookmarks/page.tsx @@ -1,14 +1,16 @@ 'use client'; -import { EdxIframe } from '@/components/edx-iframe/edx-iframe'; -import { EdxIframeContext } from '@/hooks/courses/edx-iframe-context'; -import { useContext, useEffect } from 'react'; +// @ts-ignore +import { CourseContentTabPage } from '@iblai/iblai-js/web-containers/next'; +import { config } from '@/lib/config'; export default function BookmarksTab() { - const { setActiveTab } = useContext(EdxIframeContext); - useEffect(() => { - setActiveTab('bookmarks'); - }, []); - - return ; + return ( + + ); } diff --git a/app/course-content/[course_id]/course/__tests__/page.test.tsx b/app/course-content/[course_id]/course/__tests__/page.test.tsx new file mode 100644 index 0000000..941b1fd --- /dev/null +++ b/app/course-content/[course_id]/course/__tests__/page.test.tsx @@ -0,0 +1,46 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import '@testing-library/jest-dom'; + +const capturedProps: { value?: any } = {}; +vi.mock('@iblai/iblai-js/web-containers/next', () => ({ + CourseContentTabPage: (props: any) => { + capturedProps.value = props; + return
; + }, +})); + +vi.mock('@/lib/config', () => ({ + config: { + urls: { + lms: () => 'https://lms.test', + mfe: () => 'https://mfe.test', + legacyLmsUrl: () => 'https://legacy-lms.test', + }, + }, +})); + +import CourseTab from '../page'; + +describe('CourseTab', () => { + beforeEach(() => { + capturedProps.value = undefined; + }); + + it('renders the shared CourseContentTabPage', () => { + render(); + expect(screen.getByTestId('course-content-tab-page')).toBeInTheDocument(); + }); + + it('passes tab="course" to the shared component', () => { + render(); + expect(capturedProps.value?.tab).toBe('course'); + }); + + it('passes lmsUrl, mfeUrl, and legacyLmsUrl from config', () => { + render(); + expect(capturedProps.value?.lmsUrl).toBe('https://lms.test'); + expect(capturedProps.value?.mfeUrl).toBe('https://mfe.test'); + expect(capturedProps.value?.legacyLmsUrl).toBe('https://legacy-lms.test'); + }); +}); diff --git a/app/course-content/[course_id]/course/page.tsx b/app/course-content/[course_id]/course/page.tsx index be97123..aabf9df 100644 --- a/app/course-content/[course_id]/course/page.tsx +++ b/app/course-content/[course_id]/course/page.tsx @@ -1,13 +1,16 @@ 'use client'; -import { useEffect, useContext } from 'react'; -import { EdxIframeContext } from '@/hooks/courses/edx-iframe-context'; -import { EdxIframe } from '@/components/edx-iframe/edx-iframe'; -export default function CourseTab() { - const { setActiveTab } = useContext(EdxIframeContext); - useEffect(() => { - setActiveTab('course'); - }, []); +// @ts-ignore +import { CourseContentTabPage } from '@iblai/iblai-js/web-containers/next'; +import { config } from '@/lib/config'; - return ; +export default function CourseTab() { + return ( + + ); } diff --git a/app/course-content/[course_id]/dates/__tests__/page.test.tsx b/app/course-content/[course_id]/dates/__tests__/page.test.tsx new file mode 100644 index 0000000..55cc60f --- /dev/null +++ b/app/course-content/[course_id]/dates/__tests__/page.test.tsx @@ -0,0 +1,46 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import '@testing-library/jest-dom'; + +const capturedProps: { value?: any } = {}; +vi.mock('@iblai/iblai-js/web-containers/next', () => ({ + CourseContentTabPage: (props: any) => { + capturedProps.value = props; + return
; + }, +})); + +vi.mock('@/lib/config', () => ({ + config: { + urls: { + lms: () => 'https://lms.test', + mfe: () => 'https://mfe.test', + legacyLmsUrl: () => 'https://legacy-lms.test', + }, + }, +})); + +import DatesTab from '../page'; + +describe('DatesTab', () => { + beforeEach(() => { + capturedProps.value = undefined; + }); + + it('renders the shared CourseContentTabPage', () => { + render(); + expect(screen.getByTestId('course-content-tab-page')).toBeInTheDocument(); + }); + + it('passes tab="dates" to the shared component', () => { + render(); + expect(capturedProps.value?.tab).toBe('dates'); + }); + + it('passes lmsUrl, mfeUrl, and legacyLmsUrl from config', () => { + render(); + expect(capturedProps.value?.lmsUrl).toBe('https://lms.test'); + expect(capturedProps.value?.mfeUrl).toBe('https://mfe.test'); + expect(capturedProps.value?.legacyLmsUrl).toBe('https://legacy-lms.test'); + }); +}); diff --git a/app/course-content/[course_id]/dates/page.tsx b/app/course-content/[course_id]/dates/page.tsx index bd88d2f..dea8329 100644 --- a/app/course-content/[course_id]/dates/page.tsx +++ b/app/course-content/[course_id]/dates/page.tsx @@ -1,14 +1,16 @@ 'use client'; -import { EdxIframe } from '@/components/edx-iframe/edx-iframe'; -import { EdxIframeContext } from '@/hooks/courses/edx-iframe-context'; -import { useContext, useEffect } from 'react'; +// @ts-ignore +import { CourseContentTabPage } from '@iblai/iblai-js/web-containers/next'; +import { config } from '@/lib/config'; export default function DatesTab() { - const { setActiveTab } = useContext(EdxIframeContext); - useEffect(() => { - setActiveTab('dates'); - }, []); - - return ; + return ( + + ); } diff --git a/app/course-content/[course_id]/discussion/__tests__/page.test.tsx b/app/course-content/[course_id]/discussion/__tests__/page.test.tsx new file mode 100644 index 0000000..db28777 --- /dev/null +++ b/app/course-content/[course_id]/discussion/__tests__/page.test.tsx @@ -0,0 +1,46 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import '@testing-library/jest-dom'; + +const capturedProps: { value?: any } = {}; +vi.mock('@iblai/iblai-js/web-containers/next', () => ({ + CourseContentTabPage: (props: any) => { + capturedProps.value = props; + return
; + }, +})); + +vi.mock('@/lib/config', () => ({ + config: { + urls: { + lms: () => 'https://lms.test', + mfe: () => 'https://mfe.test', + legacyLmsUrl: () => 'https://legacy-lms.test', + }, + }, +})); + +import DiscussionTab from '../page'; + +describe('DiscussionTab', () => { + beforeEach(() => { + capturedProps.value = undefined; + }); + + it('renders the shared CourseContentTabPage', () => { + render(); + expect(screen.getByTestId('course-content-tab-page')).toBeInTheDocument(); + }); + + it('passes tab="forum" to the shared component', () => { + render(); + expect(capturedProps.value?.tab).toBe('forum'); + }); + + it('passes lmsUrl, mfeUrl, and legacyLmsUrl from config', () => { + render(); + expect(capturedProps.value?.lmsUrl).toBe('https://lms.test'); + expect(capturedProps.value?.mfeUrl).toBe('https://mfe.test'); + expect(capturedProps.value?.legacyLmsUrl).toBe('https://legacy-lms.test'); + }); +}); diff --git a/app/course-content/[course_id]/discussion/page.tsx b/app/course-content/[course_id]/discussion/page.tsx index 39eb186..8302f1c 100644 --- a/app/course-content/[course_id]/discussion/page.tsx +++ b/app/course-content/[course_id]/discussion/page.tsx @@ -1,15 +1,16 @@ 'use client'; -import type React from 'react'; -import { useContext, useEffect } from 'react'; -import { EdxIframe } from '@/components/edx-iframe/edx-iframe'; -import { EdxIframeContext } from '@/hooks/courses/edx-iframe-context'; +// @ts-ignore +import { CourseContentTabPage } from '@iblai/iblai-js/web-containers/next'; +import { config } from '@/lib/config'; export default function DiscussionTab() { - const { setActiveTab } = useContext(EdxIframeContext); - useEffect(() => { - setActiveTab('forum'); - }, []); - - return ; + return ( + + ); } diff --git a/app/course-content/[course_id]/instructor/__tests__/page.test.tsx b/app/course-content/[course_id]/instructor/__tests__/page.test.tsx new file mode 100644 index 0000000..840425f --- /dev/null +++ b/app/course-content/[course_id]/instructor/__tests__/page.test.tsx @@ -0,0 +1,121 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import '@testing-library/jest-dom'; + +const { mockRedirect } = vi.hoisted(() => ({ mockRedirect: vi.fn() })); +vi.mock('next/navigation', () => ({ + redirect: mockRedirect, +})); + +const capturedProps: { value?: any } = {}; +vi.mock('@iblai/iblai-js/web-containers/next', () => ({ + CourseContentTabPage: (props: any) => { + capturedProps.value = props; + return
; + }, +})); + +vi.mock('@iblai/iblai-js/data-layer', () => ({ + useGetDepartmentMemberCheckQuery: vi.fn(() => ({ + data: { is_platform_admin: true }, + isSuccess: true, + })), +})); + +vi.mock('@/lib/config', () => ({ + config: { + urls: { + lms: () => 'https://lms.test', + mfe: () => 'https://mfe.test', + legacyLmsUrl: () => 'https://legacy-lms.test', + }, + }, +})); + +vi.mock('@/utils/helpers', () => ({ + getTenant: vi.fn(() => 'test-tenant'), +})); + +import InstructorTab from '../page'; +import { useGetDepartmentMemberCheckQuery } from '@iblai/iblai-js/data-layer'; +import { getTenant } from '@/utils/helpers'; + +describe('InstructorTab', () => { + beforeEach(() => { + vi.clearAllMocks(); + capturedProps.value = undefined; + vi.mocked(useGetDepartmentMemberCheckQuery).mockReturnValue({ + data: { is_platform_admin: true }, + isSuccess: true, + } as any); + }); + + it('renders the shared CourseContentTabPage', () => { + render(); + expect(screen.getByTestId('course-content-tab-page')).toBeInTheDocument(); + }); + + it('passes tab="instructor" to the shared component', () => { + render(); + expect(capturedProps.value?.tab).toBe('instructor'); + }); + + it('passes lmsUrl, mfeUrl, and legacyLmsUrl from config', () => { + render(); + expect(capturedProps.value?.lmsUrl).toBe('https://lms.test'); + expect(capturedProps.value?.mfeUrl).toBe('https://mfe.test'); + expect(capturedProps.value?.legacyLmsUrl).toBe('https://legacy-lms.test'); + }); + + it('calls useGetDepartmentMemberCheckQuery with tenant platform_key', () => { + render(); + expect(useGetDepartmentMemberCheckQuery).toHaveBeenCalledWith({ + platform_key: 'test-tenant', + }); + expect(getTenant).toHaveBeenCalled(); + }); + + it('does not redirect when user is a platform admin', () => { + vi.mocked(useGetDepartmentMemberCheckQuery).mockReturnValue({ + data: { is_platform_admin: true }, + isSuccess: true, + } as any); + + render(); + + expect(mockRedirect).not.toHaveBeenCalled(); + }); + + it('redirects to / when query succeeded and user is not a platform admin', () => { + vi.mocked(useGetDepartmentMemberCheckQuery).mockReturnValue({ + data: { is_platform_admin: false }, + isSuccess: true, + } as any); + + render(); + + expect(mockRedirect).toHaveBeenCalledWith('/'); + }); + + it('redirects to / when query succeeded and data is undefined', () => { + vi.mocked(useGetDepartmentMemberCheckQuery).mockReturnValue({ + data: undefined, + isSuccess: true, + } as any); + + render(); + + expect(mockRedirect).toHaveBeenCalledWith('/'); + }); + + it('does not redirect while query is pending (isSuccess=false)', () => { + vi.mocked(useGetDepartmentMemberCheckQuery).mockReturnValue({ + data: undefined, + isSuccess: false, + } as any); + + render(); + + expect(mockRedirect).not.toHaveBeenCalled(); + }); +}); diff --git a/app/course-content/[course_id]/instructor/page.tsx b/app/course-content/[course_id]/instructor/page.tsx index 7429520..5c9d828 100644 --- a/app/course-content/[course_id]/instructor/page.tsx +++ b/app/course-content/[course_id]/instructor/page.tsx @@ -1,27 +1,32 @@ 'use client'; -import type React from 'react'; -import { useContext, useEffect } from 'react'; -import { EdxIframe } from '@/components/edx-iframe/edx-iframe'; -import { EdxIframeContext } from '@/hooks/courses/edx-iframe-context'; -import { useGetDepartmentMemberCheckQuery } from '@/services/core'; -import { getTenant } from '@/utils/helpers'; +import { useEffect } from 'react'; import { redirect } from 'next/navigation'; +// @ts-ignore +import { CourseContentTabPage } from '@iblai/iblai-js/web-containers/next'; +// @ts-ignore +import { useGetDepartmentMemberCheckQuery } from '@iblai/iblai-js/data-layer'; + +import { config } from '@/lib/config'; +import { getTenant } from '@/utils/helpers'; export default function InstructorTab() { - const { setActiveTab } = useContext(EdxIframeContext); const { data: departmentMemberCheck, isSuccess } = useGetDepartmentMemberCheckQuery({ platform_key: getTenant(), }); + useEffect(() => { - if (isSuccess) { - if (!departmentMemberCheck?.is_platform_admin) { - redirect('/'); - } else { - setActiveTab('instructor'); - } + if (isSuccess && !departmentMemberCheck?.is_platform_admin) { + redirect('/'); } - }, [isSuccess, departmentMemberCheck, setActiveTab]); + }, [isSuccess, departmentMemberCheck]); - return ; + return ( + + ); } diff --git a/app/course-content/[course_id]/layout.tsx b/app/course-content/[course_id]/layout.tsx index 03415e1..6ba27b3 100644 --- a/app/course-content/[course_id]/layout.tsx +++ b/app/course-content/[course_id]/layout.tsx @@ -1,24 +1,18 @@ 'use client'; import type React from 'react'; -import { use, useEffect, useState } from 'react'; +import { use } from 'react'; -import { ChevronRight, ListTree } from 'lucide-react'; -import Link from 'next/link'; -import { useCourseDetail } from '@/hooks/courses/use-course-detail'; -import { useSearchParams } from 'next/navigation'; -import _ from 'lodash'; -import { useEdxIframe } from '@/hooks/courses/use-edx-iframe'; -import { EdxIframeContext } from '@/hooks/courses/edx-iframe-context'; -import { getTenant, getUserId } from '@/utils/helpers'; -import { CourseOutlineContext } from '@/contexts/course-outline-context'; -import { CourseOutline } from '@/components/course-outline'; -import { CourseOutlineDrawer } from '@/components/course-outline-drawer'; -import { CourseAccessGuard } from '@/components/course-access-guard'; +import { useRouter } from 'next/navigation'; +import { toast } from 'sonner'; // @ts-ignore -import { ExamInfo } from '@iblai/iblai-js/data-layer'; +import { CourseContentLayout as SharedCourseContentLayout } from '@iblai/iblai-js/web-containers/next'; +// @ts-ignore +import { useGetDepartmentMemberCheckQuery } from '@iblai/iblai-js/data-layer'; + +import { config } from '@/lib/config'; +import { getTenant } from '@/utils/helpers'; import { useChatState } from '@/components/chat-button'; -import { useGetDepartmentMemberCheckQuery } from '@/services/core'; export default function CourseContentLayout({ children, @@ -27,290 +21,35 @@ export default function CourseContentLayout({ children: React.ReactNode; params: Promise<{ course_id: string }>; }) { - const { data: departmentMemberCheck } = useGetDepartmentMemberCheckQuery({ - platform_key: getTenant(), - }); const resolvedParams = use(params); const courseId = decodeURIComponent(resolvedParams.course_id); - const searchParams = useSearchParams(); + const router = useRouter(); const { setCourseMentor } = useChatState(); - const { - handleFetchCourseInfo, - handleFetchCourseSyllabus, - handleOpenLesson, - handleFetchCourseProgress, - handleFetchCourseCompletion, - course, - courseInfoLoadingState, - courseOutline, - courseOutlineLoading, - courseCompletion, - courseGradingPolicyActive, - } = useCourseDetail(courseId); - - const { getUnitToIframe, getParentsInfosFromSublessonId } = useEdxIframe(); - - useEffect(() => { - handleFetchCourseInfo(); - handleFetchCourseProgress(); - handleFetchCourseCompletion(getUserId()); - }, [courseId]); - - useEffect(() => { - if (!_.isEmpty(course)) { - if (!course?.mentor_hidden) { - setCourseMentor(course.mentor_uuid || null); - } - handleFetchCourseSyllabus(); - } - }, [course]); - - const [expandedModule, setExpandedModule] = useState(''); - const [currentLesson, setCurrentLesson] = useState(''); - const [currentChapter, setCurrentChapter] = useState(''); - - const [expandedLessons, setExpandedLessons] = useState([]); - const [activeTab, setActiveTab] = useState('course'); - const [courseOutlineDrawerOpen, setCourseOutlineDrawerOpen] = useState(false); - const [currentlyInExamSubsection, setCurrentlyInExamSubsection] = useState(false); - const [examInfo, setExamInfo] = useState(null); - const [iframeUrl, setIframeUrl] = useState(''); - const [currentParentIds, setCurrentParentIds] = useState< - { module: Record; lesson: Record } | undefined - >(undefined); - const [currentCourseInfo, setCurrentCourseInfo] = useState | undefined>( - undefined, - ); - const [currentUnitID, setCurrentUnitID] = useState(null); - const [refresher, setRefresher] = useState(null); - useEffect(() => { - if (!_.isEmpty(courseOutline)) { - const currentCourse = getUnitToIframe(courseOutline); - setCurrentCourseInfo(currentCourse); - const unitID = currentCourse?.id; - setCurrentUnitID(unitID); - const parentsIDs = getParentsInfosFromSublessonId(courseOutline?.children || [], unitID); - setCurrentParentIds(parentsIDs); - setCurrentLesson(unitID || ''); - setExpandedLessons([parentsIDs?.lesson.id || '']); - setCurrentChapter(parentsIDs?.lesson.id || ''); - //setCurrentModule(parentsIDs?.moduleId || ""); - setExpandedModule(parentsIDs?.module.id || ''); - } - }, [searchParams, courseOutline]); - const toggleModule = (moduleId: string) => { - setExpandedModule(expandedModule === moduleId ? '' : moduleId); - }; - - const toggleLesson = (lessonId: string) => { - setExpandedLessons((prev) => - prev.includes(lessonId) ? prev.filter((id) => id !== lessonId) : [...prev, lessonId], - ); - }; - - const selectLesson = (lessonId: string) => { - setCurrentLesson(lessonId); - handleOpenLesson(lessonId); - }; + const { data: departmentMemberCheck } = useGetDepartmentMemberCheckQuery({ + platform_key: getTenant(), + }); return ( - - - -
- {/* Course sidebar */} -
-
-

{course?.display_name}

-
- - -
- - {/* Main content area */} -
- {/* Course navigation tabs */} -
-
- - Course - - - Progress - - - Dates - - - Discussion - - {departmentMemberCheck?.is_platform_admin && ( - - Instructor - - )} -
-
- -
-
- - {course?.display_name} - - {currentParentIds && currentParentIds.module.id && ( - <> - - - {currentParentIds.module.display_name} - - - )} - {currentParentIds && currentParentIds.lesson.id && ( - <> - - - {currentParentIds.lesson.display_name} - - - )} - - - {currentCourseInfo?.display_name} - -
-
-
- Progress: -
-
-
- {courseCompletion?.completion_percentage || 0}% -
- {courseGradingPolicyActive && ( -
- Grade:{' '} - {courseCompletion?.grading_percentage || 0}% -
- )} -
-
-
-
- - {/* Content area */} -
- {}, - }} - > - {children} - -
-
-
- - -
-
+ + `/course-content/${cid}/${tab === 'forum' ? 'discussion' : tab}` + } + onUnauthorized={() => router.push('/error/403')} + onNotFound={() => router.push('/error/404')} + onNavigate={(href: string, opts?: { external?: boolean }) => + opts?.external ? window.location.assign(href) : router.push(href) + } + onError={(msg: string) => toast.error(msg)} + onSuccess={(msg: string) => toast.success(msg)} + onCourseMentorChange={setCourseMentor} + > + {children} + ); } diff --git a/app/course-content/[course_id]/loading.tsx b/app/course-content/[course_id]/loading.tsx index 6d8681e..0b7e6ee 100644 --- a/app/course-content/[course_id]/loading.tsx +++ b/app/course-content/[course_id]/loading.tsx @@ -1,85 +1,8 @@ -import { Skeleton } from '@/components/ui/skeleton'; +'use client'; -export default function Loading() { - return ( -
- {/* Header skeleton */} -
- -
- {/* Sidebar skeleton */} -
-
- -
- -
- {Array(6) - .fill(0) - .map((_, i) => ( -
- - {i === 0 && ( -
- - -
- )} -
- ))} -
-
- - {/* Main content skeleton */} -
- {/* Tabs skeleton */} -
-
- {Array(5) - .fill(0) - .map((_, i) => ( - - ))} -
-
-
- - - - - -
-
- - -
-
-
+// @ts-ignore +import { CourseContentLoading } from '@iblai/iblai-js/web-containers'; - {/* Content skeleton */} -
-
- - - - -
- -
- - - - -
- -
- - -
-
-
-
-
-
- ); +export default function Loading() { + return ; } diff --git a/app/course-content/[course_id]/progress/__tests__/page.test.tsx b/app/course-content/[course_id]/progress/__tests__/page.test.tsx new file mode 100644 index 0000000..70cc4e6 --- /dev/null +++ b/app/course-content/[course_id]/progress/__tests__/page.test.tsx @@ -0,0 +1,46 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import '@testing-library/jest-dom'; + +const capturedProps: { value?: any } = {}; +vi.mock('@iblai/iblai-js/web-containers/next', () => ({ + CourseContentTabPage: (props: any) => { + capturedProps.value = props; + return
; + }, +})); + +vi.mock('@/lib/config', () => ({ + config: { + urls: { + lms: () => 'https://lms.test', + mfe: () => 'https://mfe.test', + legacyLmsUrl: () => 'https://legacy-lms.test', + }, + }, +})); + +import ProgressTab from '../page'; + +describe('ProgressTab', () => { + beforeEach(() => { + capturedProps.value = undefined; + }); + + it('renders the shared CourseContentTabPage', () => { + render(); + expect(screen.getByTestId('course-content-tab-page')).toBeInTheDocument(); + }); + + it('passes tab="progress" to the shared component', () => { + render(); + expect(capturedProps.value?.tab).toBe('progress'); + }); + + it('passes lmsUrl, mfeUrl, and legacyLmsUrl from config', () => { + render(); + expect(capturedProps.value?.lmsUrl).toBe('https://lms.test'); + expect(capturedProps.value?.mfeUrl).toBe('https://mfe.test'); + expect(capturedProps.value?.legacyLmsUrl).toBe('https://legacy-lms.test'); + }); +}); diff --git a/app/course-content/[course_id]/progress/page.tsx b/app/course-content/[course_id]/progress/page.tsx index bfec7cd..b1b0559 100644 --- a/app/course-content/[course_id]/progress/page.tsx +++ b/app/course-content/[course_id]/progress/page.tsx @@ -1,16 +1,16 @@ 'use client'; -import { EdxIframe } from '@/components/edx-iframe/edx-iframe'; -import { EdxIframeContext } from '@/hooks/courses/edx-iframe-context'; -import { useContext, useEffect } from 'react'; +// @ts-ignore +import { CourseContentTabPage } from '@iblai/iblai-js/web-containers/next'; +import { config } from '@/lib/config'; export default function ProgressTab() { - // Mock course data - const { setActiveTab } = useContext(EdxIframeContext); - useEffect(() => { - setActiveTab('progress'); - }, []); - //const { data: course } = useGetCourseQuery(resolvedParams.course_id); - - return ; + return ( + + ); } diff --git a/app/profile/__tests__/layout.test.tsx b/app/profile/__tests__/layout.test.tsx index f42d562..5c589f0 100644 --- a/app/profile/__tests__/layout.test.tsx +++ b/app/profile/__tests__/layout.test.tsx @@ -2,7 +2,7 @@ import { describe, it, expect, vi } from 'vitest'; import { render, screen } from '@testing-library/react'; import '@testing-library/jest-dom'; -vi.mock('@/components/profile-tabs', () => ({ +vi.mock('@iblai/iblai-js/web-containers/next', () => ({ ProfileTabs: () =>
, })); diff --git a/app/profile/__tests__/page.test.tsx b/app/profile/__tests__/page.test.tsx index 55906b4..3882841 100644 --- a/app/profile/__tests__/page.test.tsx +++ b/app/profile/__tests__/page.test.tsx @@ -1,21 +1,32 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { render, screen } from '@testing-library/react'; +import { render, screen, waitFor } from '@testing-library/react'; import '@testing-library/jest-dom'; -// Mock helpers vi.mock('@/utils/helpers', () => ({ getTenant: vi.fn(() => 'test-tenant'), getUserName: vi.fn(() => 'test-user'), })); -// Mock useProfileActivityStats -vi.mock('@/hooks/profile/use-profile-activity-stats', () => ({ - useProfileActivityStats: vi.fn(() => ({ - stats: [], +vi.mock('@iblai/iblai-js/web-containers', () => ({ + ProfileTimeChart: () =>
ProfileTimeChart
, + SkillLeaderboardChart: ({ userSkillPoints }: any) => ( +
+ SkillLeaderboardChart +
+ ), + SkeletonActivityStatBox: () =>
Loading...
, + useProfileActivityStats: vi.fn(() => ({ stats: [] })), + useProfileTimeSpent: vi.fn(() => ({ timeSpent: [], timeSpentLoading: false })), + useUserMetadata: vi.fn(() => ({ + userMetaData: { enable_skills_leaderboard_display: true }, + userMetaDataLoading: false, })), })); -// Mock useTenantMetadata +vi.mock('@iblai/iblai-js/web-containers/next', () => ({ + ProfileInfoCards: () =>
ProfileInfoCards
, +})); + vi.mock('@iblai/iblai-js/web-utils', () => ({ useTenantMetadata: vi.fn(() => ({ metadataLoaded: true, @@ -23,47 +34,22 @@ vi.mock('@iblai/iblai-js/web-utils', () => ({ })), })); -// Mock useGetUserMetadataQuery -vi.mock('@iblai/iblai-js/data-layer', () => ({ - useGetUserMetadataQuery: vi.fn(() => ({ - data: { enable_skills_leaderboard_display: true }, - isLoading: false, - })), -})); - -// Mock ProfileTimeChart -vi.mock('@/components/profile-time-chart', () => ({ - ProfileTimeChart: () =>
ProfileTimeChart
, -})); - -// Mock ProfileInfoCards -vi.mock('@/components/profile-info-cards', () => ({ - ProfileInfoCards: () =>
ProfileInfoCards
, -})); - -// Mock SkillLeaderboardChart -vi.mock('@/components/skill-leaderboard-chart', () => ({ - SkillLeaderboardChart: ({ userSkillPoints }: any) => ( -
- SkillLeaderboardChart -
- ), -})); - -// Mock SkeletonActivityStatBox -vi.mock('@/components/skeleton-activity-stat-box', () => ({ - SkeletonActivityStatBox: () =>
Loading...
, +const mockGetPerLearnerActivity = vi.fn(() => Promise.resolve({ data: {} })); +vi.mock('@/services/perlearner', () => ({ + useGetUserPerLearnerInfoQuery: vi.fn(() => ({ data: null, isLoading: false })), + useLazyGetPerLearnerActivityQuery: vi.fn(() => [mockGetPerLearnerActivity]), })); import ProfilePage from '../page'; -import { useProfileActivityStats } from '@/hooks/profile/use-profile-activity-stats'; +import { useProfileActivityStats, useUserMetadata } from '@iblai/iblai-js/web-containers'; import { useTenantMetadata } from '@iblai/iblai-js/web-utils'; -// @ts-ignore -import { useGetUserMetadataQuery } from '@iblai/iblai-js/data-layer'; +import { useGetUserPerLearnerInfoQuery } from '@/services/perlearner'; describe('ProfilePage', () => { beforeEach(() => { vi.clearAllMocks(); + mockGetPerLearnerActivity.mockReset(); + mockGetPerLearnerActivity.mockResolvedValue({ data: {} }); vi.mocked(useProfileActivityStats).mockReturnValue({ stats: [], } as any); @@ -71,8 +57,12 @@ describe('ProfilePage', () => { metadataLoaded: true, isSkillsLeaderBoardEnabled: vi.fn(() => false), } as any); - vi.mocked(useGetUserMetadataQuery).mockReturnValue({ - data: { enable_skills_leaderboard_display: true }, + vi.mocked(useUserMetadata).mockReturnValue({ + userMetaData: { enable_skills_leaderboard_display: true, username: 'test-user' }, + userMetaDataLoading: false, + } as any); + vi.mocked(useGetUserPerLearnerInfoQuery).mockReturnValue({ + data: null, isLoading: false, } as any); }); @@ -166,9 +156,9 @@ describe('ProfilePage', () => { metadataLoaded: true, isSkillsLeaderBoardEnabled: vi.fn(() => true), } as any); - vi.mocked(useGetUserMetadataQuery).mockReturnValue({ - data: undefined, - isLoading: true, + vi.mocked(useUserMetadata).mockReturnValue({ + userMetaData: undefined, + userMetaDataLoading: true, } as any); render(); @@ -181,9 +171,9 @@ describe('ProfilePage', () => { metadataLoaded: true, isSkillsLeaderBoardEnabled: vi.fn(() => true), } as any); - vi.mocked(useGetUserMetadataQuery).mockReturnValue({ - data: { enable_skills_leaderboard_display: false }, - isLoading: false, + vi.mocked(useUserMetadata).mockReturnValue({ + userMetaData: { enable_skills_leaderboard_display: false }, + userMetaDataLoading: false, } as any); render(); @@ -196,9 +186,9 @@ describe('ProfilePage', () => { metadataLoaded: true, isSkillsLeaderBoardEnabled: vi.fn(() => true), } as any); - vi.mocked(useGetUserMetadataQuery).mockReturnValue({ - data: { enable_skills_leaderboard_display: true }, - isLoading: false, + vi.mocked(useUserMetadata).mockReturnValue({ + userMetaData: { enable_skills_leaderboard_display: true }, + userMetaDataLoading: false, } as any); render(); @@ -212,9 +202,9 @@ describe('ProfilePage', () => { metadataLoaded: true, isSkillsLeaderBoardEnabled: vi.fn(() => true), } as any); - vi.mocked(useGetUserMetadataQuery).mockReturnValue({ - data: { enable_skills_leaderboard_display: true }, - isLoading: false, + vi.mocked(useUserMetadata).mockReturnValue({ + userMetaData: { enable_skills_leaderboard_display: true }, + userMetaDataLoading: false, } as any); vi.mocked(useProfileActivityStats).mockReturnValue({ stats: [{ loading: false, label: 'Points', value: 250 }], @@ -231,9 +221,9 @@ describe('ProfilePage', () => { metadataLoaded: true, isSkillsLeaderBoardEnabled: vi.fn(() => true), } as any); - vi.mocked(useGetUserMetadataQuery).mockReturnValue({ - data: { enable_skills_leaderboard_display: true }, - isLoading: false, + vi.mocked(useUserMetadata).mockReturnValue({ + userMetaData: { enable_skills_leaderboard_display: true }, + userMetaDataLoading: false, } as any); vi.mocked(useProfileActivityStats).mockReturnValue({ stats: [{ loading: false, label: 'Courses', value: 10 }], @@ -250,14 +240,76 @@ describe('ProfilePage', () => { metadataLoaded: true, isSkillsLeaderBoardEnabled: vi.fn(() => true), } as any); - vi.mocked(useGetUserMetadataQuery).mockReturnValue({ - data: {}, - isLoading: false, + vi.mocked(useUserMetadata).mockReturnValue({ + userMetaData: {}, + userMetaDataLoading: false, } as any); render(); - // enable_skills_leaderboard_display is undefined, not false - should show chart expect(screen.getByTestId('skill-leaderboard-chart')).toBeInTheDocument(); }); + + it('sorts per-learner activity by time_invested and picks the top entry', async () => { + mockGetPerLearnerActivity.mockResolvedValueOnce({ + data: { + data: [ + { name: 'Low', course_id: 'c-low', time_invested: 10 }, + { name: 'High', course_id: 'c-high', time_invested: 100 }, + { name: 'Mid', course_id: 'c-mid', time_invested: 50 }, + ], + }, + }); + + render(); + + await waitFor(() => { + expect(mockGetPerLearnerActivity).toHaveBeenCalledWith({ + org: 'test-tenant', + username: 'test-user', + }); + }); + + expect(screen.getByTestId('profile-info-cards')).toBeInTheDocument(); + }); + + it('falls back to placeholder topContent when per-learner activity is empty', async () => { + mockGetPerLearnerActivity.mockResolvedValueOnce({ data: {} }); + + render(); + + await waitFor(() => { + expect(mockGetPerLearnerActivity).toHaveBeenCalled(); + }); + + expect(screen.getByTestId('profile-info-cards')).toBeInTheDocument(); + }); + + it('falls back to placeholder topContent when per-learner activity rejects', async () => { + mockGetPerLearnerActivity.mockRejectedValueOnce(new Error('boom')); + + render(); + + await waitFor(() => { + expect(mockGetPerLearnerActivity).toHaveBeenCalled(); + }); + + expect(screen.getByTestId('profile-info-cards')).toBeInTheDocument(); + }); + + it('passes empty username to per-learner fetch when userMetadata has no username', async () => { + vi.mocked(useUserMetadata).mockReturnValue({ + userMetaData: { enable_skills_leaderboard_display: true }, + userMetaDataLoading: false, + } as any); + + render(); + + await waitFor(() => { + expect(mockGetPerLearnerActivity).toHaveBeenCalledWith({ + org: 'test-tenant', + username: '', + }); + }); + }); }); diff --git a/app/profile/courses/__tests__/page.test.tsx b/app/profile/courses/__tests__/page.test.tsx index 79ae058..a236951 100644 --- a/app/profile/courses/__tests__/page.test.tsx +++ b/app/profile/courses/__tests__/page.test.tsx @@ -16,33 +16,25 @@ vi.mock('@/utils/helpers', () => ({ const mockUserCourses: any[] = []; const mockPagination = { total_pages: 1, count: 0 }; -vi.mock('@/hooks/courses/use-user-courses', () => ({ +vi.mock('@iblai/iblai-js/web-containers', () => ({ useUserCourses: vi.fn(() => ({ userCourses: mockUserCourses, isLoadingUserCourses: false, errorUserCourses: false, pagination: mockPagination, })), -})); - -vi.mock('@/components/course-box', () => ({ - CourseBox: ({ course }: any) =>
{course.course_id}
, -})); - -vi.mock('@/components/skeleton-multiplier', () => ({ + getRandomCourseImage: vi.fn(() => '/fallback.png'), SkeletonMultiplier: () =>
, -})); - -vi.mock('@/components/course-card-skeleton', () => ({ CourseCardSkeleton: () =>
, -})); - -vi.mock('@/components/default-empty-box', () => ({ DefaultEmptyBox: ({ message }: { message: string }) => (
{message}
), })); +vi.mock('@iblai/iblai-js/web-containers/next', () => ({ + CourseBox: ({ course }: any) =>
{course.course_id}
, +})); + vi.mock('react-paginate', () => ({ default: ({ onPageChange }: any) => (
@@ -54,7 +46,7 @@ vi.mock('react-paginate', () => ({ })); import CoursesPage from '../page'; -import { useUserCourses } from '@/hooks/courses/use-user-courses'; +import { useUserCourses } from '@iblai/iblai-js/web-containers'; import { useTenantMetadata } from '@iblai/iblai-js/web-utils'; describe('CoursesPage', () => { diff --git a/app/profile/courses/page.tsx b/app/profile/courses/page.tsx index a5f46d6..ce062f3 100644 --- a/app/profile/courses/page.tsx +++ b/app/profile/courses/page.tsx @@ -3,15 +3,18 @@ import { useState } from 'react'; import { Search, Plus } from 'lucide-react'; -import { useUserCourses } from '@/hooks/courses/use-user-courses'; -import { CourseBox } from '@/components/course-box'; -import { SkeletonMultiplier } from '@/components/skeleton-multiplier'; -import { CourseCardSkeleton } from '@/components/course-card-skeleton'; -import { DefaultEmptyBox } from '@/components/default-empty-box'; -import { Course } from '@/types/courses'; +import { + CourseCardSkeleton, + DefaultEmptyBox, + SkeletonMultiplier, + useUserCourses, + getRandomCourseImage, +} from '@iblai/iblai-js/web-containers'; +import { CourseBox } from '@iblai/iblai-js/web-containers/next'; import AccessiblePaginate from '@/components/ui/accessible-paginate'; import { useTenantMetadata } from '@iblai/iblai-js/web-utils'; import { getTenant } from '@/utils/helpers'; +import { config } from '@/lib/config'; export default function CoursesPage() { const { metadataLoaded, isSkillsAssignmentsFeatureHidden } = useTenantMetadata({ @@ -99,9 +102,20 @@ export default function CoursesPage() { )} {!isLoadingUserCourses && !errorUserCourses && - userCourses.map((course: Course, index: number) => ( - - ))} + userCourses.map((course, index: number) => { + const fallback = getRandomCourseImage(); + const imageSrc = course.edx_data?.course_image_asset_path + ? config.urls.lms() + course.edx_data.course_image_asset_path + : fallback; + return ( + + ); + })}
{/* Pagination */}
diff --git a/app/profile/credentials/__tests__/page.test.tsx b/app/profile/credentials/__tests__/page.test.tsx index 97ccd54..6bda649 100644 --- a/app/profile/credentials/__tests__/page.test.tsx +++ b/app/profile/credentials/__tests__/page.test.tsx @@ -4,15 +4,20 @@ import '@testing-library/jest-dom'; const mockFilteredCredentials: any[] = []; -vi.mock('@/hooks/profile/use-profile-credentials', () => ({ +vi.mock('@iblai/iblai-js/web-containers', () => ({ useProfileCredentials: vi.fn(() => ({ filteredCredentials: mockFilteredCredentials, isLoading: false, isError: false, })), + CredentialMiniBoxSkeleton: () =>
, + DefaultEmptyBox: ({ message }: { message: string }) => ( +
{message}
+ ), + SkeletonMultiplier: () =>
, })); -vi.mock('@/components/credential-detail-modal', () => ({ +vi.mock('@iblai/iblai-js/web-containers/next', () => ({ CredentialDetailModal: ({ credential, onClose }: any) => (
{credential?.entityId} @@ -21,23 +26,6 @@ vi.mock('@/components/credential-detail-modal', () => ({
), -})); - -vi.mock('@/components/skeleton-multiplier', () => ({ - SkeletonMultiplier: () =>
, -})); - -vi.mock('@/components/skeleton-credential-mini-box', () => ({ - CredentialMiniBoxSkeleton: () =>
, -})); - -vi.mock('@/components/default-empty-box', () => ({ - DefaultEmptyBox: ({ message }: { message: string }) => ( -
{message}
- ), -})); - -vi.mock('@/components/credential-mini-box', () => ({ CredentialMiniBox: ({ credential, onClick }: any) => (
{credential.entityId} @@ -46,7 +34,7 @@ vi.mock('@/components/credential-mini-box', () => ({ })); import CredentialsPage from '../page'; -import { useProfileCredentials } from '@/hooks/profile/use-profile-credentials'; +import { useProfileCredentials } from '@iblai/iblai-js/web-containers'; describe('CredentialsPage', () => { beforeEach(() => { diff --git a/app/profile/credentials/page.tsx b/app/profile/credentials/page.tsx index e33fca5..2318534 100644 --- a/app/profile/credentials/page.tsx +++ b/app/profile/credentials/page.tsx @@ -2,12 +2,13 @@ import { useState } from 'react'; import { Search } from 'lucide-react'; -import { CredentialDetailModal } from '@/components/credential-detail-modal'; -import { useProfileCredentials } from '@/hooks/profile/use-profile-credentials'; -import { SkeletonMultiplier } from '@/components/skeleton-multiplier'; -import { CredentialMiniBoxSkeleton } from '@/components/skeleton-credential-mini-box'; -import { DefaultEmptyBox } from '@/components/default-empty-box'; -import { CredentialMiniBox } from '@/components/credential-mini-box'; +import { + CredentialMiniBoxSkeleton, + DefaultEmptyBox, + SkeletonMultiplier, + useProfileCredentials, +} from '@iblai/iblai-js/web-containers'; +import { CredentialDetailModal, CredentialMiniBox } from '@iblai/iblai-js/web-containers/next'; import { Assertion } from '@iblai/iblai-api'; export default function CredentialsPage() { diff --git a/app/profile/layout.tsx b/app/profile/layout.tsx index eb4a9df..9eceb76 100644 --- a/app/profile/layout.tsx +++ b/app/profile/layout.tsx @@ -1,5 +1,5 @@ 'use client'; -import { ProfileTabs } from '@/components/profile-tabs'; +import { ProfileTabs } from '@iblai/iblai-js/web-containers/next'; export default function ProfileLayout({ children }: { children: React.ReactNode }) { return ( diff --git a/app/profile/page.tsx b/app/profile/page.tsx index 4cc1df1..18e4331 100644 --- a/app/profile/page.tsx +++ b/app/profile/page.tsx @@ -1,30 +1,75 @@ 'use client'; -import { ProfileTimeChart } from '@/components/profile-time-chart'; -import { ProfileInfoCards } from '@/components/profile-info-cards'; -import { SkillLeaderboardChart } from '@/components/skill-leaderboard-chart'; -import { useProfileActivityStats } from '@/hooks/profile/use-profile-activity-stats'; -import { ActivityStats } from '@/types/catalog'; -import { SkeletonActivityStatBox } from '@/components/skeleton-activity-stat-box'; -import { getTenant, getUserName } from '@/utils/helpers'; +import { useEffect, useState } from 'react'; +import _ from 'lodash'; +import { + ProfileTimeChart, + SkillLeaderboardChart, + SkeletonActivityStatBox, + useProfileActivityStats, + useProfileTimeSpent, + useUserMetadata, + type ActivityStats, +} from '@iblai/iblai-js/web-containers'; +import { ProfileInfoCards } from '@iblai/iblai-js/web-containers/next'; +import { getTenant } from '@/utils/helpers'; import { useTenantMetadata } from '@iblai/iblai-js/web-utils'; // @ts-ignore -import { useGetUserMetadataQuery } from '@iblai/iblai-js/data-layer'; +import { useGetUserPerLearnerInfoQuery } from '@/services/perlearner'; +// @ts-ignore +import { useLazyGetPerLearnerActivityQuery } from '@/services/perlearner'; export default function ProfilePage() { const { stats } = useProfileActivityStats(); const { metadataLoaded, isSkillsLeaderBoardEnabled } = useTenantMetadata({ org: getTenant(), }); - const username = getUserName(); - const { data: userMetadata, isLoading: isUserMetadataLoading } = useGetUserMetadataQuery( - { - params: { username }, - }, - { - skip: !username, - }, - ); + const { userMetaData: userMetadata, userMetaDataLoading: isUserMetadataLoading } = + useUserMetadata(); + const { timeSpent, timeSpentLoading } = useProfileTimeSpent(); + + // ProfileInfoCards wiring + const { data: userInfo, isLoading: isUserInfoLoading } = useGetUserPerLearnerInfoQuery({ + org: getTenant(), + username: userMetadata?.username || '', + }); + const [getPerLearnerActivity] = useLazyGetPerLearnerActivityQuery(); + const [topContent, setTopContent] = useState<{ + name?: string | null; + course_id?: string | null; + time_invested?: number | null; + } | null>(null); + const [topContentLoading, setTopContentLoading] = useState(false); + + useEffect(() => { + let cancelled = false; + (async () => { + try { + setTopContentLoading(true); + const response = await getPerLearnerActivity({ + org: getTenant(), + username: userMetadata?.username || '', + }); + if (cancelled) return; + if (_.isEmpty(response.data)) { + throw new Error('Empty per-learner activity'); + } + const sortedData = [...(response.data as any).data].sort( + (a: any, b: any) => b.time_invested - a.time_invested, + ); + setTopContent(sortedData[0]); + } catch { + if (!cancelled) { + setTopContent({ name: '-', time_invested: 0, course_id: '-' }); + } + } finally { + if (!cancelled) setTopContentLoading(false); + } + })(); + return () => { + cancelled = true; + }; + }, [getPerLearnerActivity, userMetadata?.username]); return ( <> @@ -55,7 +100,7 @@ export default function ProfilePage() {

Time Spent

- +
@@ -63,7 +108,13 @@ export default function ProfilePage() {

Profile Information

- +
@@ -71,7 +122,7 @@ export default function ProfilePage() { {metadataLoaded && isSkillsLeaderBoardEnabled() && !isUserMetadataLoading && - userMetadata?.enable_skills_leaderboard_display !== false && ( + (userMetadata as any)?.enable_skills_leaderboard_display !== false && (

Skill Leaderboard

diff --git a/app/profile/pathways/__tests__/page.test.tsx b/app/profile/pathways/__tests__/page.test.tsx index 628021a..d9ebaf1 100644 --- a/app/profile/pathways/__tests__/page.test.tsx +++ b/app/profile/pathways/__tests__/page.test.tsx @@ -6,6 +6,14 @@ vi.mock('next/image', () => ({ default: ({ src, alt, ...props }: any) => {alt}, })); +vi.mock('next/navigation', () => ({ + useRouter: () => ({ push: vi.fn() }), +})); + +vi.mock('sonner', () => ({ + toast: { success: vi.fn(), error: vi.fn() }, +})); + vi.mock('@iblai/iblai-js/web-utils', () => ({ useTenantMetadata: vi.fn(() => ({ metadataLoaded: true, @@ -15,13 +23,21 @@ vi.mock('@iblai/iblai-js/web-utils', () => ({ vi.mock('@/utils/helpers', () => ({ getTenant: vi.fn(() => 'test-tenant'), - getRandomCourseImage: vi.fn(() => '/random-image.jpg'), + getUserId: vi.fn(() => 'user-id'), + getUserName: vi.fn(() => 'test-user'), + slugify: vi.fn((s: string) => s), +})); + +vi.mock('@/lib/config', () => ({ + config: { + urls: { lms: vi.fn(() => 'https://lms.example.com') }, + }, })); const mockSetFilteredPathways = vi.fn(); const mockSetPathways = vi.fn(); -vi.mock('@/hooks/profile/use-profile-pathways', () => ({ +vi.mock('@iblai/iblai-js/web-containers', () => ({ useProfilePathways: vi.fn(() => ({ filteredPathways: [], isLoading: false, @@ -31,9 +47,15 @@ vi.mock('@/hooks/profile/use-profile-pathways', () => ({ setFilteredPathways: mockSetFilteredPathways, pathwayCompletions: [], })), + getRandomCourseImage: vi.fn(() => '/random-image.jpg'), + DefaultEmptyBox: ({ message }: { message: string }) => ( +
{message}
+ ), + SkeletonMultiplier: () =>
, + SkeletonPathwayBox: () =>
, })); -vi.mock('@/components/pathway-detail-modal', () => ({ +vi.mock('@iblai/iblai-js/web-containers/next', () => ({ PathwayDetailModal: ({ pathway, onClose }: any) => (
{pathway?.name} @@ -42,9 +64,6 @@ vi.mock('@/components/pathway-detail-modal', () => ({
), -})); - -vi.mock('@/components/create-pathway-modal', () => ({ CreatePathwayModal: ({ onOpenChange, onSave }: any) => (
-
- ), -})); - -// Mock components -vi.mock('@/components/default-empty-box', () => ({ + getRandomCourseImage: vi.fn(() => '/random-course-image.jpg'), DefaultEmptyBox: ({ message }: { message: string }) => (
{message}
), -})); - -vi.mock('@/components/skeleton-multiplier', () => ({ SkeletonMultiplier: () =>
Loading...
, + SkeletonPathwayBox: () =>
Skeleton
, })); -vi.mock('@/components/skeleton-pathway-box', () => ({ - SkeletonPathwayBox: () =>
Skeleton
, +const capturedModalProps: { value?: any } = {}; +vi.mock('@iblai/iblai-js/web-containers/next', () => ({ + ProgramDetailModal: (props: any) => { + capturedModalProps.value = props; + return ( +
+ Modal for: {props.program?.name} + +
+ ); + }, })); import ProgramsPage from '../page'; -import { useProfilePrograms } from '@/hooks/profile/use-profile-programs'; +import { useProfilePrograms } from '@iblai/iblai-js/web-containers'; import { useTenantMetadata } from '@iblai/iblai-js/web-utils'; +import { useIsAdmin } from '@/utils/localstorage'; +import { + useCreateCatalogProgramSelfEnrollmentMutation, + useLazyGetProgramCompletionQuery, + useLazyGetUserEnrolledProgramsQuery, +} from '@iblai/iblai-js/data-layer'; +import { useGetProgramMetadataQuery } from '@/services/studio'; describe('ProgramsPage', () => { beforeEach(() => { vi.clearAllMocks(); + capturedModalProps.value = undefined; + // Reset stable mock references so counts don't bleed across tests + stableHandleSearch.mockReset(); + stableHandleSearch.mockResolvedValue({ data: { results: [] } }); + stableGetProgramCompletion.mockReset(); + stableGetProgramCompletion.mockResolvedValue({ data: null }); + stableGetUserEnrolledPrograms.mockReset(); + stableGetUserEnrolledPrograms.mockResolvedValue({ data: [] }); + stableCreateEnrollment.mockReset(); + stableCreateEnrollment.mockResolvedValue({}); + stableUpdateMetadata.mockReset(); + stableUpdateMetadata.mockImplementation(() => ({ unwrap: vi.fn(() => Promise.resolve()) })); + stableRefetch.mockReset(); + vi.mocked(useIsAdmin).mockReturnValue(false); + vi.mocked(useCreateCatalogProgramSelfEnrollmentMutation).mockReturnValue([ + stableCreateEnrollment, + { isError: false, isSuccess: false }, + ] as any); + vi.mocked(useLazyGetProgramCompletionQuery).mockReturnValue([ + stableGetProgramCompletion, + ] as any); + vi.mocked(useLazyGetUserEnrolledProgramsQuery).mockReturnValue([ + stableGetUserEnrolledPrograms, + { isLoading: false }, + ] as any); + vi.mocked(useGetProgramMetadataQuery).mockReturnValue({ + data: undefined, + isLoading: false, + refetch: stableRefetch, + } as any); // Reset default mocks vi.mocked(useTenantMetadata).mockReturnValue({ metadataLoaded: true, @@ -506,4 +594,429 @@ describe('ProgramsPage', () => { expect(screen.getByTestId('program-badge')).toHaveTextContent('PROGRAM'); }); + + const openProgram = async ( + overrides: Partial> = {}, + programOverrides: Partial> = {}, + ) => { + const program = { + name: 'Program 1', + program_id: 'prog-1', + program_key: 'key-1', + program_metadata: {}, + platform_key: 'test-tenant', + ended: '', + ...programOverrides, + }; + vi.mocked(useProfilePrograms).mockReturnValue({ + programs: [program], + filteredPrograms: [program], + isLoading: false, + isError: false, + setFilteredPrograms: mockSetFilteredPrograms, + setPrograms: mockSetPrograms, + programCompletions: [], + programCompletionsLoading: false, + ...overrides, + } as any); + + render(); + fireEvent.click(screen.getByTestId('program-card')); + await waitFor(() => expect(capturedModalProps.value).toBeDefined()); + return program; + }; + + it('fetches program detail data and maps courses when a program is selected', async () => { + stableHandleSearch.mockResolvedValueOnce({ + data: { + results: [ + { + courses: [ + { + course: { + course_id: 'c-1', + edx_data: { course_image_asset_path: '/img/c1.jpg' }, + }, + }, + { + course: { + course_id: 'c-1', + edx_data: { course_image_asset_path: '/img/c1.jpg' }, + }, + }, + { + course: { course_id: 'c-2', edx_data: {} }, + }, + ], + }, + { courses: undefined }, + ], + }, + }); + stableGetUserEnrolledPrograms.mockResolvedValueOnce({ + data: [{ active: true, program_id: 'prog-1' }], + }); + stableGetProgramCompletion.mockResolvedValueOnce({ + data: { completion_percentage: 40 }, + }); + + await openProgram(); + + await waitFor(() => { + expect(stableHandleSearch).toHaveBeenCalledWith( + expect.objectContaining({ + username: 'test-user', + content: ['programs'], + programId: 'prog-1', + returnItems: true, + tenant: 'test-tenant', + }), + ); + }); + + await waitFor(() => { + expect(capturedModalProps.value?.courses).toHaveLength(2); + }); + expect(capturedModalProps.value?.courses[0].course.edx_data.course_image_asset_path).toBe( + 'https://lms.example.com/img/c1.jpg', + ); + expect(capturedModalProps.value?.courses[1].course.edx_data.course_image_asset_path).toBe( + '/random-course-image.jpg', + ); + + await waitFor(() => { + expect(capturedModalProps.value?.enrollmentStatus).toBe(true); + }); + + await waitFor(() => { + expect(capturedModalProps.value?.programCompletion).toEqual({ + completion_percentage: 40, + }); + }); + }); + + it('toasts an error and clears courses when handleSearch rejects', async () => { + stableHandleSearch.mockRejectedValueOnce(new Error('boom')); + + await openProgram(); + + await waitFor(() => { + expect(toast.error).toHaveBeenCalledWith('Error fetching program details'); + }); + await waitFor(() => { + expect(capturedModalProps.value?.courses).toEqual([]); + }); + }); + + it('sets enrollmentStatus=false when getUserEnrolledPrograms rejects', async () => { + stableGetUserEnrolledPrograms.mockRejectedValueOnce(new Error('boom')); + + await openProgram(); + + await waitFor(() => { + expect(stableGetUserEnrolledPrograms).toHaveBeenCalled(); + }); + await waitFor(() => { + expect(capturedModalProps.value?.enrollmentStatus).toBe(false); + }); + }); + + it('sets enrollmentStatus=false when resp.data is not an array', async () => { + stableGetUserEnrolledPrograms.mockResolvedValueOnce({ data: null }); + + await openProgram(); + + await waitFor(() => { + expect(capturedModalProps.value?.enrollmentStatus).toBe(false); + }); + }); + + it('sets programCompletion=null when getProgramCompletion rejects', async () => { + stableGetProgramCompletion.mockRejectedValueOnce(new Error('boom')); + + await openProgram(); + + await waitFor(() => { + expect(capturedModalProps.value?.programCompletion).toBeNull(); + }); + }); + + it('uses selectedProgram.platform over platform_key for tenant in search', async () => { + await openProgram({}, { platform: 'other-tenant' }); + + await waitFor(() => { + expect(stableHandleSearch).toHaveBeenCalledWith( + expect.objectContaining({ tenant: 'other-tenant' }), + ); + }); + }); + + it('uses relative card_image as absolute LMS URL for the modal banner', async () => { + await openProgram({}, { program_metadata: { card_image: '/banners/p1.jpg' } }); + + expect(capturedModalProps.value?.bannerImageSrc).toBe('https://lms.example.com/banners/p1.jpg'); + }); + + it('uses card_image directly when it is already an absolute URL', async () => { + await openProgram( + {}, + { program_metadata: { card_image: 'https://cdn.example.com/banners/p1.jpg' } }, + ); + + expect(capturedModalProps.value?.bannerImageSrc).toBe('https://cdn.example.com/banners/p1.jpg'); + }); + + it('uses the random image as banner when no card_image is set', async () => { + await openProgram(); + expect(capturedModalProps.value?.bannerImageSrc).toBe('/random-course-image.jpg'); + }); + + it('navigates to course page when onCourseClick is invoked from modal', async () => { + await openProgram(); + + await act(async () => { + capturedModalProps.value?.onCourseClick('course-xyz'); + }); + + expect(mockRouterPush).toHaveBeenCalledWith('/courses/course-xyz'); + }); + + it('enrolls into program via onEnroll and toasts success', async () => { + await openProgram(); + + await act(async () => { + await capturedModalProps.value?.onEnroll({ program_key: 'key-1' }); + }); + + expect(stableCreateEnrollment).toHaveBeenCalledWith([ + { + requestBody: { + program_key: 'key-1', + username: 'test-user', + active: true, + ended: null, + }, + }, + ]); + expect(toast.success).toHaveBeenCalledWith('Enrolled into program successfully'); + }); + + it('toasts an error when enrollment rejects', async () => { + stableCreateEnrollment.mockRejectedValueOnce(new Error('fail')); + await openProgram(); + + await act(async () => { + await capturedModalProps.value?.onEnroll({ program_key: 'key-1' }); + }); + + expect(toast.error).toHaveBeenCalledWith('Failed to enroll into program'); + }); + + it('toasts an error when enrollment hook reports isError after call', async () => { + vi.mocked(useCreateCatalogProgramSelfEnrollmentMutation).mockReturnValue([ + stableCreateEnrollment, + { isError: true, isSuccess: false }, + ] as any); + + await openProgram(); + + await act(async () => { + await capturedModalProps.value?.onEnroll({ program_key: 'key-1' }); + }); + + expect(toast.error).toHaveBeenCalledWith('Failed to enroll into program'); + }); + + it('skips enrollment when already submitting (falls back to default program_key)', async () => { + let resolveFn: (v?: any) => void = () => {}; + stableCreateEnrollment.mockImplementationOnce( + () => new Promise((resolve) => (resolveFn = resolve)), + ); + + await openProgram(); + + // First invocation begins submission (does not await so guard is active for the second call) + act(() => { + void capturedModalProps.value?.onEnroll({ program_key: 'key-1' }); + }); + + // Second invocation while submitting should short-circuit + await act(async () => { + await capturedModalProps.value?.onEnroll({ program_key: 'key-1' }); + }); + + expect(stableCreateEnrollment).toHaveBeenCalledTimes(1); + + act(() => resolveFn({})); + }); + + it('enrolls with default empty program_key when program has no program_key', async () => { + await openProgram(); + + await act(async () => { + await capturedModalProps.value?.onEnroll({}); + }); + + expect(stableCreateEnrollment).toHaveBeenCalledWith([ + { + requestBody: { + program_key: '', + username: 'test-user', + active: true, + ended: null, + }, + }, + ]); + }); + + it('validates start_date <= end_date in onSaveSettings', async () => { + await openProgram(); + + await act(async () => { + await capturedModalProps.value?.onSaveSettings({ + start_date: '2026-05-01', + end_date: '2026-04-01', + tags: [], + topics: [], + }); + }); + + expect(toast.error).toHaveBeenCalledWith('End date must be after start date'); + expect(stableUpdateMetadata).not.toHaveBeenCalled(); + }); + + it('validates enrollment_start <= enrollment_end in onSaveSettings', async () => { + await openProgram(); + + await act(async () => { + await capturedModalProps.value?.onSaveSettings({ + enrollment_start: '2026-05-01', + enrollment_end: '2026-04-01', + tags: [], + topics: [], + }); + }); + + expect(toast.error).toHaveBeenCalledWith( + 'Enrollment end date must be after enrollment start date', + ); + expect(stableUpdateMetadata).not.toHaveBeenCalled(); + }); + + it('saves program settings happy path, refetches metadata and toasts success', async () => { + await openProgram(); + + await act(async () => { + await capturedModalProps.value?.onSaveSettings({ + slug: 'slug', + subject: 'subject', + tags: ['t1'], + level: 'beginner', + topics: ['topic'], + promotion: 'p', + social_team: 'team', + social_channels: 'ch', + description: 'desc', + display_price: '10', + start_date: '2026-01-01', + end_date: '2026-12-31', + enrollment_start: '2026-01-01', + enrollment_end: '2026-06-01', + language: 'en', + credential: 'cert', + catalog_visibility: 'both', + invitation_only: false, + banner_image: 'banner.jpg', + card_image: 'card.jpg', + }); + }); + + expect(stableUpdateMetadata).toHaveBeenCalledWith( + expect.objectContaining({ + programId: 'prog-1', + org: 'test-tenant', + settings: expect.objectContaining({ + slug: 'slug', + tags: ['t1'], + topics: ['topic'], + invitation_only: false, + platform_key: 'test-tenant', + }), + }), + ); + expect(stableRefetch).toHaveBeenCalled(); + expect(toast.success).toHaveBeenCalledWith('Program settings saved successfully'); + }); + + it('coerces empty optional fields to null and empty arrays to null in onSaveSettings', async () => { + await openProgram(); + + await act(async () => { + await capturedModalProps.value?.onSaveSettings({ + slug: '', + subject: '', + tags: [], + level: '', + topics: [], + promotion: '', + social_team: '', + social_channels: '', + description: '', + display_price: '', + start_date: '', + end_date: '', + enrollment_start: '', + enrollment_end: '', + language: '', + credential: '', + catalog_visibility: '', + invitation_only: true, + banner_image: '', + card_image: '', + }); + }); + + const call = stableUpdateMetadata.mock.calls[0][0]; + expect(call.settings.slug).toBeNull(); + expect(call.settings.tags).toBeNull(); + expect(call.settings.topics).toBeNull(); + expect(call.settings.invitation_only).toBe(true); + }); + + it('toasts an error and logs when saving settings fails', async () => { + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + stableUpdateMetadata.mockImplementationOnce(() => ({ + unwrap: vi.fn(() => Promise.reject(new Error('save fail'))), + })); + + await openProgram(); + + await act(async () => { + await capturedModalProps.value?.onSaveSettings({ tags: [], topics: [] }); + }); + + expect(toast.error).toHaveBeenCalledWith('Failed to save program settings'); + errorSpy.mockRestore(); + }); + + it('enables settings mode when admin and program belongs to tenant', async () => { + vi.mocked(useIsAdmin).mockReturnValue(true); + + await openProgram(); + + expect(capturedModalProps.value?.showSettings).toBe(true); + }); + + it('falls back to platform_key then getTenant for programOrg', async () => { + vi.mocked(useIsAdmin).mockReturnValue(true); + + await openProgram({}, { platform_key: undefined, platform: undefined, org: 'explicit-org' }); + + await act(async () => { + await capturedModalProps.value?.onSaveSettings({ tags: [], topics: [] }); + }); + + const call = stableUpdateMetadata.mock.calls[0][0]; + expect(call.org).toBe('explicit-org'); + }); }); diff --git a/app/profile/programs/page.tsx b/app/profile/programs/page.tsx index a92c998..603cfca 100644 --- a/app/profile/programs/page.tsx +++ b/app/profile/programs/page.tsx @@ -1,27 +1,47 @@ 'use client'; -import { useState } from 'react'; +import { useEffect, useState } from 'react'; import Image from 'next/image'; +import { useRouter } from 'next/navigation'; import { Search } from 'lucide-react'; -import { ProgramDetailModal } from '@/components/program-detail-modal'; -import { DefaultEmptyBox } from '@/components/default-empty-box'; -import { SkeletonMultiplier } from '@/components/skeleton-multiplier'; -import { SkeletonPathwayBox } from '@/components/skeleton-pathway-box'; -import { useProfilePrograms } from '@/hooks/profile/use-profile-programs'; +import { toast } from 'sonner'; +import { + DefaultEmptyBox, + SkeletonMultiplier, + SkeletonPathwayBox, + useProfilePrograms, + getRandomCourseImage, +} from '@iblai/iblai-js/web-containers'; +import { + ProgramDetailModal, + type ProgramSettingsFormData, + type ProgramDetailCourse, +} from '@iblai/iblai-js/web-containers/next'; +import { ProgramCompletionResponse } from '@iblai/iblai-api'; import { useTenantMetadata } from '@iblai/iblai-js/web-utils'; -import { getTenant } from '@/utils/helpers'; +import { getTenant, getUserName } from '@/utils/helpers'; +import { useIsAdmin } from '@/utils/localstorage'; import { CustomProgramEnrollmentPlus } from '@/types/program'; -import { getRandomCourseImage } from '@/utils/helpers'; import { config } from '@/lib/config'; +// @ts-ignore +import { + useLazyGetProgramCompletionQuery, + useLazyGetUserEnrolledProgramsQuery, + useCreateCatalogProgramSelfEnrollmentMutation, +} from '@iblai/iblai-js/data-layer'; +import { useGetProgramMetadataQuery, useUpdateProgramMetadataMutation } from '@/services/studio'; +import { usePersonnalizedCatalog } from '@/hooks/search/use-personnalized-catalog'; export default function ProgramsPage() { const { metadataLoaded, isSkillsAssignmentsFeatureHidden } = useTenantMetadata({ org: getTenant(), }); + const router = useRouter(); + const isAdmin = useIsAdmin(); const [searchQuery, setSearchQuery] = useState(''); const ENROLLED_TAB = 'enrolled'; const ASSIGNED_TAB = 'assigned'; - const [activeTab, setActiveTab] = useState<'enrolled' | 'assigned' | 'catalog'>(ENROLLED_TAB); // "my" or "assigned" + const [activeTab, setActiveTab] = useState<'enrolled' | 'assigned' | 'catalog'>(ENROLLED_TAB); const [selectedProgram, setSelectedProgram] = useState(null); const [randomImage] = useState(() => getRandomCourseImage()); const { @@ -45,6 +65,212 @@ export default function ProgramsPage() { setPrograms([]); }; + // ----- ProgramDetailModal wiring ----- + const { handleSearch } = usePersonnalizedCatalog(); + const [getUserEnrolledPrograms, { isLoading: isEnrollmentLoading }] = + useLazyGetUserEnrolledProgramsQuery(); + const [getProgramCompletion] = useLazyGetProgramCompletionQuery(); + const [ + createCatalogProgramSelfEnrollment, + { isError: isEnrollmentError, isSuccess: isEnrollmentSuccess }, + ] = useCreateCatalogProgramSelfEnrollmentMutation(); + + const programOrg = + (selectedProgram as any)?.org || (selectedProgram as any)?.platform_key || getTenant(); + + const showSettings = !!isAdmin && selectedProgram?.platform_key === getTenant(); + + const { + data: programMetadata, + isLoading: isLoadingMetadata, + refetch: refetchMetadata, + } = useGetProgramMetadataQuery( + { programId: selectedProgram?.program_id || '', org: programOrg }, + { skip: !selectedProgram?.program_id || !showSettings }, + ); + + const [updateProgramMetadata, { isLoading: isSavingSettings }] = + useUpdateProgramMetadataMutation(); + + const [programCourses, setProgramCourses] = useState([]); + const [programDetailLoading, setProgramDetailLoading] = useState(false); + const [programCompletion, setProgramCompletion] = useState( + null, + ); + const [enrollmentStatus, setEnrollmentStatus] = useState(false); + const [isEnrollmentSubmitting, setIsEnrollmentSubmitting] = useState(false); + + useEffect(() => { + if (!selectedProgram) { + setProgramCourses([]); + setProgramCompletion(null); + setEnrollmentStatus(false); + return; + } + let cancelled = false; + (async () => { + try { + setProgramDetailLoading(true); + const response = await handleSearch({ + username: getUserName(), + content: ['programs'], + programId: selectedProgram.program_id, + returnItems: true, + tenant: + (selectedProgram as any)?.platform || selectedProgram?.platform_key || getTenant(), + }); + const results: any[] = response?.data?.results || []; + const allCourses = results.reduce((acc: any[], program: any) => { + if (program?.courses && Array.isArray(program.courses)) { + return [...acc, ...program.courses]; + } + return acc; + }, []); + const uniqueCourses = allCourses.filter( + (course: any, index: number, self: any) => + index === self.findIndex((c: any) => c.course?.course_id === course.course?.course_id), + ); + const mapped: ProgramDetailCourse[] = uniqueCourses.map((course: any) => ({ + ...course, + course: { + ...course?.course, + edx_data: { + ...course?.course?.edx_data, + course_image_asset_path: course?.course?.edx_data?.course_image_asset_path + ? config.urls.lms() + course.course.edx_data.course_image_asset_path + : getRandomCourseImage(), + }, + }, + })); + if (!cancelled) setProgramCourses(mapped); + } catch { + if (!cancelled) { + toast.error('Error fetching program details'); + setProgramCourses([]); + } + } finally { + if (!cancelled) setProgramDetailLoading(false); + } + })(); + (async () => { + try { + const resp = await getUserEnrolledPrograms([ + { + username: getUserName(), + programId: selectedProgram.program_id || '', + }, + ]); + if (!cancelled) { + setEnrollmentStatus( + Array.isArray(resp.data) && + resp.data.findIndex( + (pre: any) => pre.active && pre?.program_id === selectedProgram.program_id, + ) !== -1, + ); + } + } catch { + if (!cancelled) setEnrollmentStatus(false); + } + })(); + (async () => { + try { + const resp = await getProgramCompletion([ + { + programKey: selectedProgram.program_key || '', + username: getUserName(), + }, + ]); + if (!cancelled) setProgramCompletion((resp.data as ProgramCompletionResponse) || null); + } catch { + if (!cancelled) setProgramCompletion(null); + } + })(); + return () => { + cancelled = true; + }; + }, [selectedProgram, handleSearch, getProgramCompletion, getUserEnrolledPrograms]); + + const handleEnrollIntoProgram = async (program: any) => { + if (isEnrollmentSubmitting) return; + try { + setIsEnrollmentSubmitting(true); + await createCatalogProgramSelfEnrollment([ + { + requestBody: { + program_key: program.program_key || '', + username: getUserName(), + active: true, + ended: null, + }, + }, + ]); + if (isEnrollmentError) { + throw new Error('Failed to enroll into program'); + } + toast.success('Enrolled into program successfully'); + setTimeout(() => setIsEnrollmentSubmitting(false), 500); + } catch { + toast.error('Failed to enroll into program'); + setIsEnrollmentSubmitting(false); + } + }; + + const handleSaveSettings = async (settingsForm: ProgramSettingsFormData) => { + if (settingsForm.start_date && settingsForm.end_date) { + if (new Date(settingsForm.end_date) < new Date(settingsForm.start_date)) { + toast.error('End date must be after start date'); + return; + } + } + if (settingsForm.enrollment_start && settingsForm.enrollment_end) { + if (new Date(settingsForm.enrollment_end) < new Date(settingsForm.enrollment_start)) { + toast.error('Enrollment end date must be after enrollment start date'); + return; + } + } + try { + const settings = { + slug: settingsForm.slug || null, + subject: settingsForm.subject || null, + tags: settingsForm.tags.length > 0 ? settingsForm.tags : null, + level: settingsForm.level || null, + topics: settingsForm.topics.length > 0 ? settingsForm.topics : null, + promotion: settingsForm.promotion || null, + social_team: settingsForm.social_team || null, + social_channels: settingsForm.social_channels || null, + description: settingsForm.description || null, + display_price: settingsForm.display_price || null, + start_date: settingsForm.start_date || null, + end_date: settingsForm.end_date || null, + enrollment_start: settingsForm.enrollment_start || null, + enrollment_end: settingsForm.enrollment_end || null, + language: settingsForm.language || null, + credential: settingsForm.credential || null, + catalog_visibility: settingsForm.catalog_visibility || null, + invitation_only: settingsForm.invitation_only, + banner_image: settingsForm.banner_image || null, + card_image: settingsForm.card_image || null, + platform_key: programOrg, + }; + await updateProgramMetadata({ + programId: selectedProgram?.program_id || '', + org: programOrg, + settings, + }).unwrap(); + refetchMetadata(); + toast.success('Program settings saved successfully'); + } catch (error) { + console.error('Error saving program settings:', error); + toast.error('Failed to save program settings'); + } + }; + + const selectedProgramBannerSrc = selectedProgram?.program_metadata?.card_image + ? String(selectedProgram.program_metadata.card_image).startsWith('http') + ? (selectedProgram.program_metadata.card_image as string) + : config.urls.lms() + selectedProgram.program_metadata.card_image + : randomImage; + return ( <>
@@ -73,20 +299,9 @@ export default function ProgramsPage() { Assigned programs )} - {/* */}
- {/* Search Bar and Create Program Button */}
@@ -112,14 +327,12 @@ export default function ProgramsPage() { )} - {/* Programs Grid */}
- {/* Program Cards */} {isLoading && } {!isLoading && !isError && filteredPrograms.length > 0 && - filteredPrograms.map((program: CustomProgramEnrollmentPlus, index: number) => ( + filteredPrograms.map((program: any, index: number) => (
{program.name || ''} {programCompletions.length > 0 && programCompletions[index] && (
- {programCompletions.length > 0 && programCompletions[index] && ( - <> -
- Progress - - {programCompletions[index].completion_percentage || 0}% - -
-
-
-
- - )} +
+ Progress + + {programCompletions[index].completion_percentage || 0}% + +
+
+
+
)}
@@ -178,9 +387,26 @@ export default function ProgramsPage() { ))}
- {/* Program Detail Modal */} {selectedProgram && ( - setSelectedProgram(null)} /> + setSelectedProgram(null)} + onEnroll={handleEnrollIntoProgram} + onCourseClick={(courseId) => router.push(`/courses/${courseId}`)} + onSaveSettings={handleSaveSettings} + /> )} ); diff --git a/app/profile/public/__tests__/page.test.tsx b/app/profile/public/__tests__/page.test.tsx index 072312f..a7b5a4a 100644 --- a/app/profile/public/__tests__/page.test.tsx +++ b/app/profile/public/__tests__/page.test.tsx @@ -3,7 +3,6 @@ import { render, screen, fireEvent } from '@testing-library/react'; import '@testing-library/jest-dom'; import React from 'react'; -// Mock lucide-react icons vi.mock('lucide-react', () => ({ Facebook: () => Facebook, Linkedin: () => Linkedin, @@ -12,12 +11,16 @@ vi.mock('lucide-react', () => ({ Edit: () => Edit, })); -// Mock helpers +vi.mock('sonner', () => ({ + toast: { success: vi.fn(), error: vi.fn() }, +})); + vi.mock('@/utils/helpers', () => ({ getTenant: vi.fn(() => 'test-tenant'), + getUserName: vi.fn(() => 'test-user'), + onAccountDeleted: vi.fn(), })); -// Mock config vi.mock('@/lib/config', () => ({ config: { settings: { @@ -31,28 +34,13 @@ vi.mock('@/lib/config', () => ({ }, })); -// Mock useUserMetadata -vi.mock('@/hooks/users/use-usermetadata', () => ({ - useUserMetadata: vi.fn(() => ({ - userMetaData: { - name: 'John Doe', - bio: 'Software Engineer', - about: 'About me section', - social_links: [], - }, - })), -})); - -// Mock useTenantMetadata vi.mock('@iblai/iblai-js/web-utils', () => ({ useTenantMetadata: vi.fn(() => ({ metadataLoaded: true, isSkillsResumeFeatureHidden: vi.fn(() => false), })), - Tenant: {}, })); -// Mock useIsAdmin, useUserTenants vi.mock('@/utils/localstorage', () => ({ useIsAdmin: vi.fn(() => false), useUserTenants: vi.fn(() => ({ @@ -61,7 +49,6 @@ vi.mock('@/utils/localstorage', () => ({ })), })); -// Mock useAppSelector and selectRbacPermissions vi.mock('@/lib/hooks', () => ({ useAppSelector: vi.fn(() => ({})), })); @@ -70,8 +57,47 @@ vi.mock('@/features/rbac', () => ({ selectRbacPermissions: vi.fn(), })); -// Mock UserProfileModal +vi.mock('@iblai/iblai-js/web-containers', () => ({ + CredentialBox: () =>
CredentialBox
, + EducationBox: () =>
EducationBox
, + ExperienceBox: () =>
ExperienceBox
, + ResumeBox: () =>
ResumeBox
, + SkillsBox: () =>
SkillsBox
, + UserAvatar: ({ size, containerClassName }: any) => ( +
+ UserAvatar +
+ ), + EducationDialog: () =>
EducationDialog
, + ExperienceDialog: () =>
ExperienceDialog
, + useProfileCredentials: vi.fn(() => ({ + fetchedCredentials: [], + isLoading: false, + isError: false, + })), + useProfileSkills: vi.fn(() => ({ + earnedSkills: [], + earnedSkillsLoading: false, + earnedSkillsError: false, + selfReportedSkills: [], + selfReportedSkillsLoading: false, + selfReportedSkillsError: false, + desiredSkills: [], + desiredSkillsLoading: false, + desiredSkillsError: false, + })), + useUserMetadata: vi.fn(() => ({ + userMetaData: { + name: 'John Doe', + bio: 'Software Engineer', + about: 'About me section', + social_links: [], + }, + })), +})); + vi.mock('@iblai/iblai-js/web-containers/next', () => ({ + MediaBox: () =>
MediaBox
, UserProfileModal: ({ isOpen, onClose, targetTab }: any) => isOpen ? (
@@ -83,41 +109,32 @@ vi.mock('@iblai/iblai-js/web-containers/next', () => ({ ) : null, })); -// Mock UserAvatar -vi.mock('@/components/header/profile/user-avatar', () => ({ - UserAvatar: ({ size, containerClassName }: any) => ( -
- UserAvatar -
- ), -})); - -// Mock profile components -vi.mock('@/components/profile/education-box', () => ({ - EducationBox: () =>
EducationBox
, -})); - -vi.mock('@/components/profile/experience-box', () => ({ - ExperienceBox: () =>
ExperienceBox
, -})); - -vi.mock('@/components/profile/skills-box', () => ({ - SkillsBox: () =>
SkillsBox
, +// Stable references to keep hook return values referentially stable across renders +const stableCreateUserResume = vi.fn(); + +vi.mock('@iblai/iblai-js/data-layer', () => ({ + useGetUserEducationQuery: vi.fn(() => ({ data: [], isLoading: false, error: null })), + useGetUserExperienceQuery: vi.fn(() => ({ data: [], isLoading: false, error: null })), + useGetUserResumeQuery: vi.fn(() => ({ + data: { files: [], links: [] }, + isLoading: false, + isError: false, + refetch: vi.fn(), + })), })); -vi.mock('@/components/profile/credential-box', () => ({ - CredentialBox: () =>
CredentialBox
, +vi.mock('@/services/career', () => ({ + useCreateUserResumeMutation: vi.fn(() => [stableCreateUserResume, { isLoading: false }]), })); -vi.mock('@/components/profile/resume-box', () => ({ - ResumeBox: () =>
ResumeBox
, +vi.mock('@/components/add-institution-dialog', () => ({ + AddInstitutionDialog: () =>
, })); -vi.mock('@/components/profile/media-box', () => ({ - MediaBox: () =>
MediaBox
, +vi.mock('@/components/add-company-dialog', () => ({ + AddCompanyDialog: () =>
, })); -// Mock AppContext from client-layout const mockSetIsUserProfileOpen = vi.fn(); const mockSetUserProfileTargetTab = vi.fn(); @@ -132,11 +149,10 @@ vi.mock('@/components/client-layout', () => ({ import PublicProfilePage from '../page'; import { AppContext } from '@/components/client-layout'; -import { useUserMetadata } from '@/hooks/users/use-usermetadata'; +import { useUserMetadata } from '@iblai/iblai-js/web-containers'; import { useTenantMetadata } from '@iblai/iblai-js/web-utils'; import { useUserTenants } from '@/utils/localstorage'; -// Helper component that provides context function renderWithContext(isUserProfileOpen = false, userProfileTargetTab = 'basic') { return render( { } as any); renderWithContext(); - // Bio should not show expect(screen.queryByText('Software Engineer')).not.toBeInTheDocument(); }); @@ -378,7 +393,6 @@ describe('PublicProfilePage', () => { renderWithContext(true, 'basic'); - // The UserProfileModal is rendered - we just verify the mock was set up correctly expect(screen.getByTestId('user-profile-modal')).toBeInTheDocument(); }); }); diff --git a/app/profile/public/page.tsx b/app/profile/public/page.tsx index 5b84af1..5932ade 100644 --- a/app/profile/public/page.tsx +++ b/app/profile/public/page.tsx @@ -2,29 +2,162 @@ import { useState, useContext, useCallback } from 'react'; import { Facebook, Linkedin, Twitter, Edit2, Edit } from 'lucide-react'; -import { useUserMetadata } from '@/hooks/users/use-usermetadata'; -import { EducationBox } from '@/components/profile/education-box'; -import { ExperienceBox } from '@/components/profile/experience-box'; -import { SkillsBox } from '@/components/profile/skills-box'; -import { CredentialBox } from '@/components/profile/credential-box'; -import { ResumeBox } from '@/components/profile/resume-box'; -import { MediaBox } from '@/components/profile/media-box'; -import { UserAvatar } from '@/components/header/profile/user-avatar'; +import { toast } from 'sonner'; +import { + CredentialBox, + EducationBox, + ExperienceBox, + ResumeBox, + SkillsBox, + UserAvatar, + useProfileCredentials, + useProfileSkills, + useUserMetadata, +} from '@iblai/iblai-js/web-containers'; +import { EducationDialog, ExperienceDialog } from '@iblai/iblai-js/web-containers'; +import { + MediaBox, + type UploadedFile as PortedUploadedFile, +} from '@iblai/iblai-js/web-containers/next'; import { AppContext } from '@/components/client-layout'; +import { AddInstitutionDialog } from '@/components/add-institution-dialog'; +import { AddCompanyDialog } from '@/components/add-company-dialog'; import { useTenantMetadata } from '@iblai/iblai-js/web-utils'; -import { getTenant, onAccountDeleted } from '@/utils/helpers'; +import { getTenant, getUserName, onAccountDeleted } from '@/utils/helpers'; import { config } from '@/lib/config'; import { Tenant } from '@iblai/iblai-js/web-utils'; import { UserProfileModal } from '@iblai/iblai-js/web-containers/next'; import { useIsAdmin, useUserTenants } from '@/utils/localstorage'; import { useAppSelector } from '@/lib/hooks'; import { selectRbacPermissions } from '@/features/rbac'; +import { Education, Experience } from '@iblai/iblai-api'; +// @ts-ignore +import { + useGetUserEducationQuery, + useGetUserExperienceQuery, + useGetUserResumeQuery, +} from '@iblai/iblai-js/data-layer'; +import { useCreateUserResumeMutation } from '@/services/career'; export default function PublicProfilePage() { const { userMetaData } = useUserMetadata(); const { metadataLoaded, isSkillsResumeFeatureHidden } = useTenantMetadata({ org: getTenant(), }); + const enableGravatar = config.settings.enableGravatarOnProfilePic() !== 'false'; + + // Education + const { + data: educationData, + isLoading: educationLoading, + error: educationError, + } = useGetUserEducationQuery([{ org: getTenant(), username: getUserName() }]); + const [editEducationOpen, setEditEducationOpen] = useState(false); + const [selectedEducation, setSelectedEducation] = useState(undefined); + const [openAddInstitutionDialog, setOpenAddInstitutionDialog] = useState(false); + + // Experience + const { + data: experienceData, + isLoading: experienceLoading, + error: experienceError, + } = useGetUserExperienceQuery([{ org: getTenant(), username: getUserName() }]); + const [editExperienceOpen, setEditExperienceOpen] = useState(false); + const [selectedExperience, setSelectedExperience] = useState(undefined); + const [openAddCompanyDialog, setOpenAddCompanyDialog] = useState(false); + + // Skills + const { + earnedSkills, + earnedSkillsLoading, + earnedSkillsError, + selfReportedSkills, + selfReportedSkillsLoading, + selfReportedSkillsError, + desiredSkills, + desiredSkillsLoading, + desiredSkillsError, + } = useProfileSkills(); + + // Credentials + const { + fetchedCredentials, + isLoading: credentialsLoading, + isError: credentialsError, + } = useProfileCredentials({ search: '' }); + + // Resume + Media (uses same useGetUserResumeQuery) + const { + data: resumeData, + isLoading: resumeLoading, + isError: resumeError, + refetch: refetchResume, + } = useGetUserResumeQuery([{ org: getTenant(), username: getUserName() }]); + const [createUserResume, { isLoading: isUploading }] = useCreateUserResumeMutation(); + + const resumeUrl = Array.isArray((resumeData as any)?.files) + ? ((resumeData as any).files.find((f: any) => f.type === 'resume')?.url as string | undefined) + : undefined; + + const uploadedMedia: PortedUploadedFile[] = (() => { + const files = Array.isArray((resumeData as any)?.files) ? (resumeData as any).files : []; + const links = Array.isArray((resumeData as any)?.links) + ? (resumeData as any).links.map((link: any) => ({ + name: link.url, + url: link.url, + type: 'link', + })) + : []; + return [...files, ...links]; + })(); + + const handleUploadFile = async (file: File, isResume: boolean) => { + const formData = new FormData(); + formData.append('user', getUserName()); + formData.append('platform', getTenant()); + if (isResume) { + formData.append('resume', file); + } else { + formData.append('additional_files', file); + formData.append('file_type_portfolio_sample.pdf', 'portfolio'); + } + try { + await createUserResume({ + username: getUserName(), + platform_key: getTenant(), + resume: formData, + method: 'POST', + }); + refetchResume(); + toast.success('Media uploaded successfully'); + } catch { + toast.error('Error uploading media'); + } + }; + + const handleUploadLink = async (url: string) => { + const formData = new FormData(); + formData.append('user', getUserName()); + formData.append('platform', getTenant()); + const existingLinks: any[] = (resumeData as any)?.links || []; + const totalLinks = existingLinks.length; + existingLinks.forEach((link: any, index: number) => { + formData.append('link_' + (totalLinks + 1 - index), link?.url || ''); + }); + formData.append('link_1', url); + try { + await createUserResume({ + username: getUserName(), + platform_key: getTenant(), + resume: formData, + }); + refetchResume(); + toast.success('Media uploaded successfully'); + } catch { + toast.error('Error uploading media'); + } + }; + const { isUserProfileOpen, setIsUserProfileOpen, userProfileTargetTab, setUserProfileTargetTab } = useContext(AppContext); const [activeTab, setActiveTab] = useState('about'); // about, education, experience, skills, credentials, resume, media @@ -87,7 +220,11 @@ export default function PublicProfilePage() { {/* Profile Picture */}
- +
@@ -161,21 +298,105 @@ export default function PublicProfilePage() {
)} - {activeTab === 'education' && } + {activeTab === 'education' && ( + { + setSelectedEducation(edu); + setEditEducationOpen(true); + }} + /> + )} - {activeTab === 'experience' && } + {activeTab === 'experience' && ( + { + setSelectedExperience(exp); + setEditExperienceOpen(true); + }} + /> + )} - {activeTab === 'skills' && } + {activeTab === 'skills' && ( + + )} - {activeTab === 'credentials' && } + {activeTab === 'credentials' && ( + + )} {activeTab === 'resume' && metadataLoaded && !isSkillsResumeFeatureHidden() && ( - + )} - {activeTab === 'media' && } + {activeTab === 'media' && ( + toast.error(message)} + /> + )}
+ {editEducationOpen && ( + setEditEducationOpen(false)} + /> + )} + {openAddInstitutionDialog && ( + setOpenAddInstitutionDialog(false)} + /> + )} + {editExperienceOpen && ( + setEditExperienceOpen(false)} + /> + )} + {openAddCompanyDialog && ( + setOpenAddCompanyDialog(false)} + /> + )} {isUserProfileOpen && ( ({ }, })); -// Mock useProfileSkills +// Mock useProfileSkills and related SDK components const mockHandleSkillsDeletion = vi.fn(); const mockHandleSkillsUpdate = vi.fn(); +const mockHandleSkillsCreate = vi.fn(() => Promise.resolve(true)); +const mockHandleFetchAllSkills = vi.fn(); -vi.mock('@/hooks/profile/use-profile-skills', () => ({ +vi.mock('@iblai/iblai-js/web-containers', () => ({ useProfileSkills: vi.fn(() => ({ earnedSkills: null, earnedSkillsLoading: false, @@ -41,11 +43,12 @@ vi.mock('@/hooks/profile/use-profile-skills', () => ({ updatingSkill: false, deletingSkill: false, handleSkillsUpdate: mockHandleSkillsUpdate, + handleSkillsCreate: mockHandleSkillsCreate, + handleFetchAllSkills: mockHandleFetchAllSkills, + fetchedSkills: null, + isFetchingSkills: false, + isFetchingSkillsError: false, })), -})); - -// Mock AddSkillDialog -vi.mock('@/components/add-skill-dialog', () => ({ AddSkillDialog: ({ open, onOpenChange, type }: any) => open ? (
@@ -55,10 +58,6 @@ vi.mock('@/components/add-skill-dialog', () => ({
) : null, -})); - -// Mock SkillDetailModal -vi.mock('@/components/skill-detail-modal', () => ({ SkillDetailModal: ({ skill, onClose, onRatingChange, onDeleteSkill, onConfirm }: any) => (
SkillDetailModal @@ -76,10 +75,6 @@ vi.mock('@/components/skill-detail-modal', () => ({
), -})); - -// Mock SkillBox -vi.mock('@/components/skill-box', () => ({ SkillBox: ({ skill, onSkillClick, showRating }: any) => (
({ {skill?.name}
), -})); - -// Mock SkeletonSkillBox -vi.mock('@/components/skeleton-skill-box', () => ({ SkeletonSkillBox: () =>
Loading...
, -})); - -// Mock DefaultEmptyBox -vi.mock('@/components/default-empty-box', () => ({ DefaultEmptyBox: ({ message, className }: any) => (
{message}
), -})); - -// Mock SkeletonMultiplier -vi.mock('@/components/skeleton-multiplier', () => ({ SkeletonMultiplier: ({ multiplier }: any) => (
Loading skeletons @@ -115,8 +98,12 @@ vi.mock('@/components/skeleton-multiplier', () => ({ ), })); +vi.mock('sonner', () => ({ + toast: { success: vi.fn(), error: vi.fn() }, +})); + import SkillsPage from '../page'; -import { useProfileSkills } from '@/hooks/profile/use-profile-skills'; +import { useProfileSkills } from '@iblai/iblai-js/web-containers'; describe('SkillsPage', () => { beforeEach(() => { @@ -138,6 +125,11 @@ describe('SkillsPage', () => { updatingSkill: false, deletingSkill: false, handleSkillsUpdate: mockHandleSkillsUpdate, + handleSkillsCreate: mockHandleSkillsCreate, + handleFetchAllSkills: mockHandleFetchAllSkills, + fetchedSkills: null, + isFetchingSkills: false, + isFetchingSkillsError: false, } as any); }); diff --git a/app/profile/skills/page.tsx b/app/profile/skills/page.tsx index b629433..676b653 100644 --- a/app/profile/skills/page.tsx +++ b/app/profile/skills/page.tsx @@ -2,15 +2,19 @@ import { useState, useRef } from 'react'; import { Search, Plus, ChevronLeft, ChevronRight } from 'lucide-react'; -import { AddSkillDialog } from '@/components/add-skill-dialog'; -import { SkillDetailModal } from '@/components/skill-detail-modal'; -import { useProfileSkills } from '@/hooks/profile/use-profile-skills'; -import { SkillBox } from '@/components/skill-box'; -import { SkeletonSkillBox } from '@/components/skeleton-skill-box'; -import { DefaultEmptyBox } from '@/components/default-empty-box'; +import { + AddSkillDialog, + DefaultEmptyBox, + SkeletonMultiplier, + SkeletonSkillBox, + SkillBox, + SkillDetailModal, + useProfileSkills, + type UserSkill, +} from '@iblai/iblai-js/web-containers'; +import { toast } from 'sonner'; import _ from 'lodash'; -import { SkeletonMultiplier } from '@/components/skeleton-multiplier'; -import { UserSkill } from '@/types/skills'; +import type { Skill } from '@iblai/iblai-api'; export default function SkillsPage() { const { @@ -30,6 +34,11 @@ export default function SkillsPage() { updatingSkill, deletingSkill, handleSkillsUpdate, + handleSkillsCreate, + handleFetchAllSkills, + fetchedSkills, + isFetchingSkills, + isFetchingSkillsError, } = useProfileSkills(); const [searchQuery, setSearchQuery] = useState(''); const [addSkillDialogOpen, setAddSkillDialogOpen] = useState(false); @@ -355,9 +364,30 @@ export default function SkillsPage() { open={addSkillDialogOpen} onOpenChange={setAddSkillDialogOpen} type={skillTypeToAdd} - existingSkills={{ - selfReported: selfReportedSkills, - desired: desiredSkills, + fetchedSkills={fetchedSkills as any} + isFetchingSkills={isFetchingSkills} + isFetchingSkillsError={isFetchingSkillsError} + updatingSkill={updatingSkill} + onSearch={(query) => handleFetchAllSkills(query)} + onAddSkill={async (skill: Skill) => { + const targetBucket = skillTypeToAdd === 'desired' ? desiredSkills : selfReportedSkills; + const existing = (targetBucket?.skills || []) as any[]; + const existingLevels = (targetBucket?.data?.level || []) as number[]; + const newPayload: any = { + skills: [...existing, { name: skill.name }], + data: { + ...(targetBucket?.data || {}), + level: [...existingLevels, 0], + }, + type: skillTypeToAdd, + }; + const ok = await handleSkillsCreate(newPayload); + if (ok) { + toast.success('Skill added successfully'); + setAddSkillDialogOpen(false); + } else { + toast.error('Failed to add skill'); + } }} /> {/* Skill Detail Modal */} diff --git a/components/__tests__/course-outline-drawer.test.tsx b/components/__tests__/course-outline-drawer.test.tsx deleted file mode 100644 index 018e673..0000000 --- a/components/__tests__/course-outline-drawer.test.tsx +++ /dev/null @@ -1,94 +0,0 @@ -import { describe, it, expect, vi } from 'vitest'; -import { render, screen } from '@testing-library/react'; -import '@testing-library/jest-dom'; -import React from 'react'; - -vi.mock('@/components/ui/sheet', () => ({ - Sheet: ({ children, open }: any) => (open ?
{children}
: null), - SheetContent: ({ children }: any) =>
{children}
, - SheetHeader: ({ children }: any) =>
{children}
, - SheetTitle: ({ children }: any) =>

{children}

, -})); - -vi.mock('../course-outline', () => ({ - CourseOutline: () =>
Course Outline Content
, -})); - -import { CourseOutlineContext } from '@/contexts/course-outline-context'; -import { CourseOutlineDrawer } from '../course-outline-drawer'; - -describe('CourseOutlineDrawer', () => { - const mockSetCourseOutlineDrawerOpen = vi.fn(); - - const createWrapper = (overrides = {}) => { - const contextValue = { - courseOutline: {} as any, - courseOutlineLoading: false, - expandedModule: '', - expandedLessons: [], - selectLesson: vi.fn(), - toggleModule: vi.fn(), - toggleLesson: vi.fn(), - currentChapter: '', - currentLesson: '', - course: { display_name: 'Test Course' } as any, - courseOutlineDrawerOpen: true, - setCourseOutlineDrawerOpen: mockSetCourseOutlineDrawerOpen, - currentUnitID: null, - refetchCourseOutline: vi.fn(), - ...overrides, - }; - - function CourseOutlineDrawerTestWrapper({ children }: { children: React.ReactNode }) { - return ( - - {children} - - ); - } - - return CourseOutlineDrawerTestWrapper; - }; - - it('renders without crashing when open', () => { - const Wrapper = createWrapper(); - const { container } = render(, { wrapper: Wrapper }); - expect(container).toBeTruthy(); - }); - - it('does not render when closed', () => { - const Wrapper = createWrapper({ courseOutlineDrawerOpen: false }); - const { queryByTestId } = render(, { wrapper: Wrapper }); - expect(queryByTestId('sheet')).not.toBeInTheDocument(); - }); - - it('renders the course display name', () => { - const Wrapper = createWrapper(); - render(, { wrapper: Wrapper }); - expect(screen.getByText('Test Course')).toBeInTheDocument(); - }); - - it('renders the CourseOutline component', () => { - const Wrapper = createWrapper(); - render(, { wrapper: Wrapper }); - expect(screen.getByTestId('course-outline')).toBeInTheDocument(); - }); - - it('handles null course gracefully', () => { - const Wrapper = createWrapper({ course: null }); - const { container } = render(, { wrapper: Wrapper }); - expect(container).toBeTruthy(); - }); - - it('renders sheet header', () => { - const Wrapper = createWrapper(); - render(, { wrapper: Wrapper }); - expect(screen.getByTestId('sheet-header')).toBeInTheDocument(); - }); - - it('renders sheet content', () => { - const Wrapper = createWrapper(); - render(, { wrapper: Wrapper }); - expect(screen.getByTestId('sheet-content')).toBeInTheDocument(); - }); -}); diff --git a/components/__tests__/course-outline.test.tsx b/components/__tests__/course-outline.test.tsx deleted file mode 100644 index 2ba2a94..0000000 --- a/components/__tests__/course-outline.test.tsx +++ /dev/null @@ -1,358 +0,0 @@ -import { describe, it, expect, vi } from 'vitest'; -import { render, screen, fireEvent } from '@testing-library/react'; -import { CourseOutline } from '../course-outline'; -import { CourseOutlineContext, CourseOutlineContextType } from '@/contexts/course-outline-context'; -import { CourseOutlineChildNode } from '@/types/courses'; -import '@testing-library/jest-dom'; - -vi.mock('../skeleton-multiplier', () => ({ - SkeletonMultiplier: () =>
, -})); - -vi.mock('../skeleton-course-outline', () => ({ - SkeletonCourseOutline: () =>
, -})); - -const makeNode = (overrides: Partial = {}): CourseOutlineChildNode => ({ - id: 'node-1', - block_id: 'block-1', - type: 'html', - display_name: 'Node 1', - ...overrides, -}); - -const defaultContext: CourseOutlineContextType = { - courseOutline: {} as CourseOutlineChildNode, - courseOutlineLoading: false, - expandedModule: '', - expandedLessons: [], - selectLesson: vi.fn(), - toggleModule: vi.fn(), - toggleLesson: vi.fn(), - currentChapter: '', - currentLesson: '', - course: null, - courseOutlineDrawerOpen: false, - setCourseOutlineDrawerOpen: vi.fn(), - currentUnitID: null, - refetchCourseOutline: vi.fn(), -}; - -const renderWithContext = (ctx: Partial = {}) => - render( - - - , - ); - -describe('CourseOutline', () => { - it('renders skeleton when loading', () => { - renderWithContext({ courseOutlineLoading: true }); - expect(screen.getByTestId('skeleton')).toBeInTheDocument(); - }); - - it('renders module names', () => { - const modules = [ - makeNode({ id: 'mod-1', display_name: 'Module 1', children: [] }), - makeNode({ id: 'mod-2', display_name: 'Module 2', children: [] }), - ]; - renderWithContext({ - courseOutline: makeNode({ id: 'root', display_name: 'Root', children: modules }), - }); - expect(screen.getByText('Module 1')).toBeInTheDocument(); - expect(screen.getByText('Module 2')).toBeInTheDocument(); - }); - - it('shows lessons when module is expanded', () => { - const modules = [ - makeNode({ - id: 'mod-1', - display_name: 'Module 1', - children: [ - makeNode({ id: 'lesson-1', display_name: 'Lesson 1' }), - makeNode({ id: 'lesson-2', display_name: 'Lesson 2' }), - ], - }), - ]; - renderWithContext({ - courseOutline: makeNode({ id: 'root', display_name: 'Root', children: modules }), - expandedModule: 'mod-1', - }); - expect(screen.getByText('Lesson 1')).toBeInTheDocument(); - expect(screen.getByText('Lesson 2')).toBeInTheDocument(); - }); - - it('calls toggleModule on module click', () => { - const toggleModule = vi.fn(); - const modules = [makeNode({ id: 'mod-1', display_name: 'Module 1' })]; - renderWithContext({ - courseOutline: makeNode({ id: 'root', display_name: 'Root', children: modules }), - toggleModule, - }); - fireEvent.click(screen.getByText('Module 1')); - expect(toggleModule).toHaveBeenCalledWith('mod-1'); - }); -}); - -describe('CompletionIcon rendering', () => { - it('renders empty circle for incomplete leaf node', () => { - const modules = [ - makeNode({ - id: 'mod-1', - display_name: 'Module 1', - children: [makeNode({ id: 'lesson-1', display_name: 'Lesson 1', complete: false })], - }), - ]; - const { container } = renderWithContext({ - courseOutline: makeNode({ id: 'root', display_name: 'Root', children: modules }), - expandedModule: 'mod-1', - }); - const svgs = container.querySelectorAll('svg'); - // The lesson's completion icon SVG - // svgs[0] is the module's ChevronRight, svgs[1] is the CompletionIcon - const lessonSvg = svgs[1]; - expect(lessonSvg).toBeTruthy(); - // Empty circle has gray stroke (#d1d5db) - const circle = lessonSvg.querySelector('circle'); - expect(circle?.getAttribute('stroke')).toBe('#d1d5db'); - }); - - it('renders filled amber check for fully complete leaf node', () => { - const modules = [ - makeNode({ - id: 'mod-1', - display_name: 'Module 1', - children: [makeNode({ id: 'lesson-1', display_name: 'Lesson 1', complete: true })], - }), - ]; - const { container } = renderWithContext({ - courseOutline: makeNode({ id: 'root', display_name: 'Root', children: modules }), - expandedModule: 'mod-1', - }); - const svgs = container.querySelectorAll('svg'); - // svgs[0] is the module's ChevronRight, svgs[1] is the CompletionIcon - const lessonSvg = svgs[1]; - // Filled circle has amber fill (#f59e0b) - const circle = lessonSvg.querySelector('circle'); - expect(circle?.getAttribute('fill')).toBe('#f59e0b'); - // Has a checkmark path - const path = lessonSvg.querySelector('path'); - expect(path).toBeTruthy(); - }); - - it('renders partial progress for parent with mixed children completion', () => { - const modules = [ - makeNode({ - id: 'mod-1', - display_name: 'Module 1', - children: [ - makeNode({ - id: 'lesson-1', - display_name: 'Lesson 1', - children: [ - makeNode({ id: 'sub-1', display_name: 'Sub 1', complete: true }), - makeNode({ id: 'sub-2', display_name: 'Sub 2', complete: true }), - makeNode({ id: 'sub-3', display_name: 'Sub 3', complete: false }), - makeNode({ id: 'sub-4', display_name: 'Sub 4', complete: false }), - ], - }), - ], - }), - ]; - const { container } = renderWithContext({ - courseOutline: makeNode({ id: 'root', display_name: 'Root', children: modules }), - expandedModule: 'mod-1', - }); - const svgs = container.querySelectorAll('svg'); - // svgs[0] is the module's ChevronRight, svgs[1] is the CompletionIcon - const lessonSvg = svgs[1]; - // Partial progress has two circles (background + progress arc) - const circles = lessonSvg.querySelectorAll('circle'); - expect(circles.length).toBe(2); - // The progress arc has amber stroke - const progressCircle = circles[1]; - expect(progressCircle.getAttribute('stroke')).toBe('#f59e0b'); - }); - - it('renders full completion when all children are complete', () => { - const modules = [ - makeNode({ - id: 'mod-1', - display_name: 'Module 1', - children: [ - makeNode({ - id: 'lesson-1', - display_name: 'Lesson 1', - children: [ - makeNode({ id: 'sub-1', display_name: 'Sub 1', complete: true }), - makeNode({ id: 'sub-2', display_name: 'Sub 2', complete: true }), - ], - }), - ], - }), - ]; - const { container } = renderWithContext({ - courseOutline: makeNode({ id: 'root', display_name: 'Root', children: modules }), - expandedModule: 'mod-1', - }); - const svgs = container.querySelectorAll('svg'); - // svgs[0] is the module's ChevronRight, svgs[1] is the CompletionIcon - const lessonSvg = svgs[1]; - // Full completion: amber filled circle with checkmark - const circle = lessonSvg.querySelector('circle'); - expect(circle?.getAttribute('fill')).toBe('#f59e0b'); - const path = lessonSvg.querySelector('path'); - expect(path).toBeTruthy(); - }); - - it('renders empty circle when no children are complete', () => { - const modules = [ - makeNode({ - id: 'mod-1', - display_name: 'Module 1', - children: [ - makeNode({ - id: 'lesson-1', - display_name: 'Lesson 1', - children: [ - makeNode({ id: 'sub-1', display_name: 'Sub 1', complete: false }), - makeNode({ id: 'sub-2', display_name: 'Sub 2', complete: false }), - ], - }), - ], - }), - ]; - const { container } = renderWithContext({ - courseOutline: makeNode({ id: 'root', display_name: 'Root', children: modules }), - expandedModule: 'mod-1', - }); - const svgs = container.querySelectorAll('svg'); - // svgs[0] is the module's ChevronRight, svgs[1] is the CompletionIcon - const lessonSvg = svgs[1]; - // Empty: single circle with gray stroke - const circles = lessonSvg.querySelectorAll('circle'); - expect(circles.length).toBe(1); - expect(circles[0].getAttribute('stroke')).toBe('#d1d5db'); - }); - - it('renders sublessons when a lesson is expanded', () => { - const modules = [ - makeNode({ - id: 'mod-1', - display_name: 'Module 1', - children: [ - makeNode({ - id: 'lesson-1', - display_name: 'Lesson 1', - children: [ - makeNode({ id: 'sub-1', display_name: 'Sub 1', complete: false }), - makeNode({ id: 'sub-2', display_name: 'Sub 2', complete: true }), - ], - }), - ], - }), - ]; - renderWithContext({ - courseOutline: makeNode({ id: 'root', display_name: 'Root', children: modules }), - expandedModule: 'mod-1', - expandedLessons: ['lesson-1'], - }); - expect(screen.getByText('Sub 1')).toBeInTheDocument(); - expect(screen.getByText('Sub 2')).toBeInTheDocument(); - }); - - it('calls selectLesson when a sublesson is clicked', () => { - const selectLesson = vi.fn(); - const modules = [ - makeNode({ - id: 'mod-1', - display_name: 'Module 1', - children: [ - makeNode({ - id: 'lesson-1', - display_name: 'Lesson 1', - children: [makeNode({ id: 'sub-1', display_name: 'Sub 1', complete: false })], - }), - ], - }), - ]; - renderWithContext({ - courseOutline: makeNode({ id: 'root', display_name: 'Root', children: modules }), - expandedModule: 'mod-1', - expandedLessons: ['lesson-1'], - selectLesson, - }); - fireEvent.click(screen.getByText('Sub 1')); - expect(selectLesson).toHaveBeenCalledWith('sub-1'); - }); - - it('highlights the current sublesson', () => { - const modules = [ - makeNode({ - id: 'mod-1', - display_name: 'Module 1', - children: [ - makeNode({ - id: 'lesson-1', - display_name: 'Lesson 1', - children: [ - makeNode({ id: 'sub-1', display_name: 'Sub 1', complete: false }), - makeNode({ id: 'sub-2', display_name: 'Sub 2', complete: false }), - ], - }), - ], - }), - ]; - renderWithContext({ - courseOutline: makeNode({ id: 'root', display_name: 'Root', children: modules }), - expandedModule: 'mod-1', - expandedLessons: ['lesson-1'], - currentLesson: 'sub-1', - }); - const sub1Button = screen.getByText('Sub 1').closest('button'); - expect(sub1Button?.className).toContain('bg-amber-50'); - }); - - it('calculates recursive completion correctly for deeply nested nodes', () => { - // Parent with 2 children: one fully complete, one half complete - // Expected ratio: (1 + 0.5) / 2 = 0.75 => level = round(0.75 * 7) = 5 - const modules = [ - makeNode({ - id: 'mod-1', - display_name: 'Module 1', - children: [ - makeNode({ - id: 'lesson-1', - display_name: 'Lesson 1', - children: [ - makeNode({ - id: 'sub-1', - display_name: 'Sub 1', - complete: true, - }), - makeNode({ - id: 'sub-2', - display_name: 'Sub 2', - children: [ - makeNode({ id: 'unit-1', display_name: 'Unit 1', complete: true }), - makeNode({ id: 'unit-2', display_name: 'Unit 2', complete: false }), - ], - }), - ], - }), - ], - }), - ]; - const { container } = renderWithContext({ - courseOutline: makeNode({ id: 'root', display_name: 'Root', children: modules }), - expandedModule: 'mod-1', - }); - const svgs = container.querySelectorAll('svg'); - // svgs[0] is the module's ChevronRight, svgs[1] is the CompletionIcon - const lessonSvg = svgs[1]; - // Partial progress (level 5 of 7) -> two circles - const circles = lessonSvg.querySelectorAll('circle'); - expect(circles.length).toBe(2); - expect(circles[1].getAttribute('stroke')).toBe('#f59e0b'); - }); -}); diff --git a/components/__tests__/skeleton-course-outline.test.tsx b/components/__tests__/skeleton-course-outline.test.tsx deleted file mode 100644 index 25955b2..0000000 --- a/components/__tests__/skeleton-course-outline.test.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import { describe, it, expect, vi } from 'vitest'; -import { render } from '@testing-library/react'; -import '@testing-library/jest-dom'; -import { SkeletonCourseOutline } from '../skeleton-course-outline'; - -vi.mock('@/components/ui/skeleton', () => ({ - Skeleton: ({ className }: { className?: string }) => ( -
- ), -})); - -describe('SkeletonCourseOutline', () => { - it('renders without crashing', () => { - const { container } = render(); - expect(container.firstChild).toBeInTheDocument(); - }); -}); diff --git a/components/course-outline-drawer.tsx b/components/course-outline-drawer.tsx deleted file mode 100644 index eb204be..0000000 --- a/components/course-outline-drawer.tsx +++ /dev/null @@ -1,24 +0,0 @@ -'use client'; - -import { CourseOutlineContext } from '@/contexts/course-outline-context'; -import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet'; -import { useContext } from 'react'; -import { CourseOutline } from './course-outline'; - -export function CourseOutlineDrawer() { - const { course, courseOutlineDrawerOpen, setCourseOutlineDrawerOpen } = - useContext(CourseOutlineContext); - - return ( - - - - - {course?.display_name} - - - - - - ); -} diff --git a/components/course-outline.tsx b/components/course-outline.tsx deleted file mode 100644 index fcfbcea..0000000 --- a/components/course-outline.tsx +++ /dev/null @@ -1,187 +0,0 @@ -import { CourseOutlineContext } from '@/contexts/course-outline-context'; -import { SkeletonMultiplier } from './skeleton-multiplier'; -import { ChevronRight } from 'lucide-react'; -import { useContext } from 'react'; -import { SkeletonCourseOutline } from './skeleton-course-outline'; -import { CourseOutlineChildNode } from '@/types/courses'; - -const MAX_CHECKMARK_POINT = 7; - -const getCompletionRatio = (node: CourseOutlineChildNode): number => { - if (!Array.isArray(node.children) || node.children.length === 0) { - return node.complete ? 1 : 0; - } - const totalChildren = node.children.length; - const completedScore = node.children.reduce((acc, child) => acc + getCompletionRatio(child), 0); - return completedScore / totalChildren; -}; - -const getCompletionLevel = (node: CourseOutlineChildNode): number => { - return Math.round(getCompletionRatio(node) * MAX_CHECKMARK_POINT); -}; - -const CompletionIcon = ({ node }: { node: CourseOutlineChildNode }) => { - const level = getCompletionLevel(node); - const size = 16; - const strokeWidth = 2; - const radius = (size - strokeWidth) / 2; - const circumference = 2 * Math.PI * radius; - const progress = level / MAX_CHECKMARK_POINT; - const dashOffset = circumference * (1 - progress); - - if (level === MAX_CHECKMARK_POINT) { - // Fully complete - filled check circle - return ( - - - - - ); - } - - if (level === 0) { - // No progress - empty circle - return ( - - - - ); - } - - // Partial progress - arc circle - return ( - - - - - ); -}; - -export const CourseOutline = () => { - const { - courseOutline, - courseOutlineLoading, - expandedModule, - expandedLessons, - selectLesson, - toggleModule, - toggleLesson, - currentChapter, - currentLesson, - } = useContext(CourseOutlineContext); - return ( -
- {courseOutlineLoading ? ( - - ) : ( - Array.isArray(courseOutline?.children) && - courseOutline.children.map((module) => ( -
- - - {expandedModule === module.id && module.children && ( -
- {module.children.map((lesson) => ( -
- - - {lesson.children && - lesson.children.length > 0 && - expandedLessons.includes(lesson.id) && ( -
- {lesson.children.map((sublesson) => ( - - ))} -
- )} -
- ))} -
- )} -
- )) - )} -
- ); -}; diff --git a/components/edx-iframe/__tests__/edx-iframe.test.tsx b/components/edx-iframe/__tests__/edx-iframe.test.tsx deleted file mode 100644 index 9465d8f..0000000 --- a/components/edx-iframe/__tests__/edx-iframe.test.tsx +++ /dev/null @@ -1,299 +0,0 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { render, waitFor, act, fireEvent } from '@testing-library/react'; -import { EdxIframe } from '../edx-iframe'; -import { EdxIframeContext } from '@/hooks/courses/edx-iframe-context'; -import { CourseOutlineContext } from '@/contexts/course-outline-context'; -import { LOCALSTORAGE_KEYS } from '@/constants/storage'; -import '@testing-library/jest-dom'; - -// Mock dependencies -vi.mock('next/navigation', () => ({ - useSearchParams: () => new URLSearchParams(), -})); - -vi.mock('@/hooks/courses/use-edx-iframe', () => ({ - useEdxIframe: () => ({ - getIframeURL: vi.fn((_courseId, _tab, callback) => { - callback('https://apps.learn.example.com/discussions/course-v1:test+course/posts'); - }), - findSequentialParent: vi.fn(() => null), - }), -})); - -vi.mock('@/hooks/courses/useCourseNavigator', () => ({ - default: () => ({ - navigator: { - moveToPrevious: vi.fn(() => null), - moveToNext: vi.fn(() => null), - isPreviousHidden: vi.fn(() => true), - isNextHidden: vi.fn(() => true), - thirdLevelChildren: [], - currentIndex: 0, - }, - }), -})); - -vi.mock('@iblai/iblai-js/data-layer', () => ({ - useLazyGetExamInfoQuery: () => [vi.fn()], -})); - -vi.mock('use-debounce', () => ({ - useDebouncedCallback: (fn: any) => fn, -})); - -vi.mock('../timed-exam', () => ({ - TimedExam: () =>
Timed Exam
, -})); - -describe('EdxIframe - JWT PostMessage', () => { - const mockSetIframeUrl = vi.fn(); - const mockSetActiveTab = vi.fn(); - const mockSetCurrentlyInExamSubsection = vi.fn(); - const mockSetExamInfo = vi.fn(); - const mockSelectLesson = vi.fn(); - const mockRefetchCourseOutline = vi.fn(); - - const defaultContextValue = { - iframeUrl: 'https://apps.learn.example.com/discussions/course-v1:test+course/posts', - setIframeUrl: mockSetIframeUrl, - courseOutline: { - id: 'root', - block_id: 'root-block', - type: 'course', - display_name: 'Test Course', - children: [ - { - id: 'test', - block_id: 'test-block', - type: 'chapter', - display_name: 'Test Chapter', - children: [], - }, - ], - }, // Non-empty to trigger course load - setActiveTab: mockSetActiveTab, - activeTab: 'forum', - courseID: 'course-v1:test+course', - currentlyInExamSubsection: false, - setCurrentlyInExamSubsection: mockSetCurrentlyInExamSubsection, - examInfo: null, - setExamInfo: mockSetExamInfo, - refresher: null, - setRefresher: vi.fn(), - }; - - const defaultCourseOutlineValue = { - courseOutline: {} as any, - courseOutlineLoading: false, - expandedModule: '', - expandedLessons: [], - selectLesson: mockSelectLesson, - toggleModule: vi.fn(), - toggleLesson: vi.fn(), - currentChapter: '', - currentLesson: '', - course: null, - courseOutlineDrawerOpen: false, - setCourseOutlineDrawerOpen: vi.fn(), - currentUnitID: null, - refetchCourseOutline: mockRefetchCourseOutline, - }; - - beforeEach(() => { - vi.clearAllMocks(); - localStorage.clear(); - }); - - afterEach(() => { - vi.restoreAllMocks(); - }); - - const renderEdxIframe = ( - contextValue = defaultContextValue, - courseOutlineValue = defaultCourseOutlineValue, - ) => { - return render( - - - - - , - ); - }; - - it('renders loading state initially', () => { - const { container } = renderEdxIframe(); - - // Component should render something (either loading or iframe) - expect(container.firstChild).toBeTruthy(); - }); - - it('renders iframe after loading completes', async () => { - const { container } = renderEdxIframe(); - - await waitFor( - () => { - const iframe = container.querySelector('iframe'); - expect(iframe).toBeInTheDocument(); - }, - { timeout: 1000 }, - ); - }); - - it('sets iframe src from context', async () => { - const { container } = renderEdxIframe(); - - await waitFor( - () => { - const iframe = container.querySelector('iframe'); - expect(iframe).toBeInTheDocument(); - expect(iframe?.getAttribute('src')).toBe(defaultContextValue.iframeUrl); - }, - { timeout: 1000 }, - ); - }); - - it('responds to JWT ready message from iframe', async () => { - const testToken = 'test-jwt-token-12345'; - localStorage.setItem(LOCALSTORAGE_KEYS.EDX_TOKEN_KEY, testToken); - - const { container } = renderEdxIframe(); - - await waitFor( - () => { - const iframe = container.querySelector('iframe'); - expect(iframe).toBeInTheDocument(); - }, - { timeout: 1000 }, - ); - - const iframe = container.querySelector('iframe'); - const mockPostMessage = vi.fn(); - - // Mock contentWindow - Object.defineProperty(iframe, 'contentWindow', { - value: { postMessage: mockPostMessage }, - writable: true, - configurable: true, - }); - - // Simulate the MFE sending a ready message - await act(async () => { - window.dispatchEvent( - new MessageEvent('message', { - data: { type: 'auth.jwt.ready' }, - origin: 'https://apps.learn.example.com', - }), - ); - }); - - await waitFor(() => { - expect(mockPostMessage).toHaveBeenCalledWith( - { - type: 'auth.jwt.token', - edx_jwt_token: testToken, - }, - 'https://apps.learn.example.com', - ); - }); - }); - - it('does not send JWT token if not in localStorage', async () => { - // Don't set any token in localStorage - const { container } = renderEdxIframe(); - - await waitFor( - () => { - const iframe = container.querySelector('iframe'); - expect(iframe).toBeInTheDocument(); - }, - { timeout: 1000 }, - ); - - const iframe = container.querySelector('iframe'); - const mockPostMessage = vi.fn(); - - Object.defineProperty(iframe, 'contentWindow', { - value: { postMessage: mockPostMessage }, - writable: true, - configurable: true, - }); - - await act(async () => { - window.dispatchEvent( - new MessageEvent('message', { - data: { type: 'auth.jwt.ready' }, - origin: 'https://apps.learn.example.com', - }), - ); - }); - - // Should not have been called since no token - expect(mockPostMessage).not.toHaveBeenCalled(); - }); - - it('calls refetchCourseOutline when iframe loads', async () => { - const { container } = renderEdxIframe(); - - await waitFor( - () => { - const iframe = container.querySelector('iframe'); - expect(iframe).toBeInTheDocument(); - }, - { timeout: 1000 }, - ); - - const iframe = container.querySelector('iframe'); - expect(iframe).toBeTruthy(); - - await act(async () => { - fireEvent.load(iframe!); - }); - - expect(mockRefetchCourseOutline).toHaveBeenCalledWith(false); - }); - - it('rejects messages from wrong origin', async () => { - const testToken = 'test-jwt-token-12345'; - localStorage.setItem(LOCALSTORAGE_KEYS.EDX_TOKEN_KEY, testToken); - const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); - - const { container } = renderEdxIframe(); - - await waitFor( - () => { - const iframe = container.querySelector('iframe'); - expect(iframe).toBeInTheDocument(); - }, - { timeout: 1000 }, - ); - - const iframe = container.querySelector('iframe'); - const mockPostMessage = vi.fn(); - - Object.defineProperty(iframe, 'contentWindow', { - value: { postMessage: mockPostMessage }, - writable: true, - configurable: true, - }); - - // Simulate message from wrong origin - await act(async () => { - window.dispatchEvent( - new MessageEvent('message', { - data: { type: 'auth.jwt.ready' }, - origin: 'https://malicious-site.com', - }), - ); - }); - - // Should not have been called due to origin mismatch - expect(mockPostMessage).not.toHaveBeenCalled(); - expect(consoleErrorSpy).toHaveBeenCalledWith( - expect.stringContaining('Origin mismatch'), - expect.any(Object), - ); - - consoleErrorSpy.mockRestore(); - }); -}); diff --git a/components/edx-iframe/__tests__/timed-exam.test.tsx b/components/edx-iframe/__tests__/timed-exam.test.tsx deleted file mode 100644 index 886787c..0000000 --- a/components/edx-iframe/__tests__/timed-exam.test.tsx +++ /dev/null @@ -1,588 +0,0 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { render, screen, fireEvent, act } from '@testing-library/react'; -import '@testing-library/jest-dom'; -import React from 'react'; -import _ from 'lodash'; -import { EdxIframeContext } from '@/hooks/courses/edx-iframe-context'; - -const mockUpdateExamAttempt = vi.fn(); -const mockStartExam = vi.fn(); -const mockGetExamInfo = vi.fn(); - -vi.mock('@iblai/iblai-js/data-layer', () => ({ - useUpdateExamAttemptMutation: vi.fn(() => [mockUpdateExamAttempt, { isLoading: false }]), - useStartExamMutation: vi.fn(() => [mockStartExam, { isLoading: false }]), - useLazyGetExamInfoQuery: vi.fn(() => [mockGetExamInfo]), -})); - -vi.mock('lodash', () => ({ - default: { - isEmpty: vi.fn((val: any) => { - if (val === null || val === undefined) return true; - if (typeof val === 'object' && !Array.isArray(val)) return Object.keys(val).length === 0; - if (Array.isArray(val)) return val.length === 0; - return false; - }), - }, -})); - -import { TimedExam } from '../timed-exam'; - -const buildContextValue = (overrides = {}) => ({ - iframeUrl: '', - setIframeUrl: vi.fn(), - courseOutline: {} as any, - setActiveTab: vi.fn(), - activeTab: '', - courseID: 'course-v1:test+101', - currentlyInExamSubsection: false, - setCurrentlyInExamSubsection: vi.fn(), - examInfo: null, - setExamInfo: vi.fn(), - refresher: null, - setRefresher: vi.fn(), - ...overrides, -}); - -const noAttemptExamInfo = { - exam: { - id: 42, - exam_name: 'Midterm Exam', - time_limit_mins: 90, - course_id: 'course-v1:test+101', - content_id: 'block-v1:test+101+type@sequential+block@abc', - attempt: {}, - }, - active_attempt: {}, -}; - -const startedExamInfo = { - exam: { - id: 42, - exam_name: 'Midterm Exam', - time_limit_mins: 90, - course_id: 'course-v1:test+101', - content_id: 'block-v1:test+101+type@sequential+block@abc', - attempt: { - attempt_id: 'attempt-1', - attempt_status: 'started', - time_remaining_seconds: 5400, - low_threshold_sec: 3600, - critically_low_threshold_sec: 1800, - }, - }, - active_attempt: { attempt_id: 'attempt-1' }, -}; - -const submittedExamInfo = { - exam: { - id: 42, - exam_name: 'Midterm Exam', - time_limit_mins: 90, - course_id: 'course-v1:test+101', - content_id: 'block-v1:test+101+type@sequential+block@abc', - attempt: { - attempt_id: 'attempt-1', - attempt_status: 'submitted', - }, - }, - active_attempt: null, -}; - -const renderTimedExam = (contextOverrides = {}) => { - const contextValue = buildContextValue(contextOverrides); - return render( - - - , - ); -}; - -describe('TimedExam', () => { - beforeEach(() => { - vi.clearAllMocks(); - vi.useFakeTimers(); - // Re-apply lodash mock implementation - vi.mocked(_.isEmpty).mockImplementation((val: any) => { - if (val === null || val === undefined) return true; - if (typeof val === 'object' && !Array.isArray(val)) return Object.keys(val).length === 0; - if (Array.isArray(val)) return val.length === 0; - return false; - }); - mockUpdateExamAttempt.mockReturnValue({ - unwrap: vi.fn().mockResolvedValue({}), - }); - mockStartExam.mockReturnValue({ - unwrap: vi.fn().mockResolvedValue({}), - }); - mockGetExamInfo.mockResolvedValue({ data: startedExamInfo }); - }); - - afterEach(() => { - vi.useRealTimers(); - }); - - it('returns null when examInfo is null', () => { - const { container } = renderTimedExam({ examInfo: null }); - expect(container.firstChild).toBeNull(); - }); - - it('returns null when exam is submitted', () => { - const { container } = renderTimedExam({ examInfo: submittedExamInfo }); - expect(container.firstChild).toBeNull(); - }); - - it('renders ready-to-start UI when no attempt exists', () => { - renderTimedExam({ examInfo: noAttemptExamInfo }); - expect(screen.getByText(/Midterm Exam is a Timed Exam/)).toBeInTheDocument(); - }); - - it('renders time limit in hours and minutes format (mixed)', () => { - const examWith90Mins = { - ...noAttemptExamInfo, - exam: { ...noAttemptExamInfo.exam, time_limit_mins: 90 }, - }; - renderTimedExam({ examInfo: examWith90Mins }); - // Use getAllByText since the time appears in multiple elements - const elements = screen.getAllByText(/1 hour 30 minutes/); - expect(elements.length).toBeGreaterThan(0); - }); - - it('renders time limit in hours only (plural)', () => { - const examWith120Mins = { - ...noAttemptExamInfo, - exam: { ...noAttemptExamInfo.exam, time_limit_mins: 120 }, - }; - renderTimedExam({ examInfo: examWith120Mins }); - const elements = screen.getAllByText(/2 hours/); - expect(elements.length).toBeGreaterThan(0); - }); - - it('renders time limit in hours only (singular)', () => { - const examWith60Mins = { - ...noAttemptExamInfo, - exam: { ...noAttemptExamInfo.exam, time_limit_mins: 60 }, - }; - renderTimedExam({ examInfo: examWith60Mins }); - const elements = screen.getAllByText(/1 hour/); - expect(elements.length).toBeGreaterThan(0); - }); - - it('renders time limit in minutes only (plural)', () => { - const examWith30Mins = { - ...noAttemptExamInfo, - exam: { ...noAttemptExamInfo.exam, time_limit_mins: 30 }, - }; - renderTimedExam({ examInfo: examWith30Mins }); - const elements = screen.getAllByText(/30 minutes/); - expect(elements.length).toBeGreaterThan(0); - }); - - it('renders time limit in minutes only (singular)', () => { - const examWith1Min = { - ...noAttemptExamInfo, - exam: { ...noAttemptExamInfo.exam, time_limit_mins: 1 }, - }; - renderTimedExam({ examInfo: examWith1Min }); - const elements = screen.getAllByText(/1 minute/); - expect(elements.length).toBeGreaterThan(0); - }); - - it('renders start exam button', () => { - renderTimedExam({ examInfo: noAttemptExamInfo }); - // The text appears in button and in sr-only div, use getAllByText - const elements = screen.getAllByText(/I am ready to start this timed exam/); - expect(elements.length).toBeGreaterThan(0); - }); - - it('renders additional time allowance section', () => { - renderTimedExam({ examInfo: noAttemptExamInfo }); - expect(screen.getByText(/Can I request additional time/)).toBeInTheDocument(); - }); - - it('calls handleStartExam when start button clicked', async () => { - mockStartExam.mockReturnValue({ unwrap: vi.fn().mockResolvedValue({}) }); - mockGetExamInfo.mockResolvedValue({ data: startedExamInfo }); - renderTimedExam({ examInfo: noAttemptExamInfo }); - - // Get the actual button (not sr-only text) - const startBtn = screen.getByRole('button', { name: /I am ready to start this timed exam/ }); - await act(async () => { - fireEvent.click(startBtn); - }); - expect(mockStartExam).toHaveBeenCalled(); - }); - - it('handles start exam error gracefully', async () => { - const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); - mockStartExam.mockReturnValue({ - unwrap: vi.fn().mockRejectedValue(new Error('Start failed')), - }); - renderTimedExam({ examInfo: noAttemptExamInfo }); - - const startBtn = screen.getByRole('button', { name: /I am ready to start this timed exam/ }); - await act(async () => { - fireEvent.click(startBtn); - }); - expect(consoleSpy).toHaveBeenCalledWith('Failed to start exam:', expect.any(Error)); - consoleSpy.mockRestore(); - }); - - it('renders timer UI when exam is started', () => { - renderTimedExam({ examInfo: startedExamInfo }); - expect(screen.getByText(/You are taking "Midterm Exam" as a timed exam/)).toBeInTheDocument(); - }); - - it('shows time remaining in hours:mm:ss format', () => { - renderTimedExam({ examInfo: startedExamInfo }); - // 5400 seconds = 1:30:00 - expect(screen.getByText('1:30:00')).toBeInTheDocument(); - }); - - it('shows time remaining in mm:ss format for sub-hour', () => { - const examWithShortTime = { - ...startedExamInfo, - exam: { - ...startedExamInfo.exam, - attempt: { - ...startedExamInfo.exam.attempt, - time_remaining_seconds: 305, - }, - }, - }; - renderTimedExam({ examInfo: examWithShortTime }); - // 305 seconds = 5:05 - expect(screen.getByText('5:05')).toBeInTheDocument(); - }); - - it('shows End My Exam button when exam is started', () => { - renderTimedExam({ examInfo: startedExamInfo }); - expect(screen.getByText('End My Exam')).toBeInTheDocument(); - }); - - it('shows Show more link', () => { - renderTimedExam({ examInfo: startedExamInfo }); - expect(screen.getByText('Show more')).toBeInTheDocument(); - }); - - it('toggles to full instructions when Show more is clicked', () => { - renderTimedExam({ examInfo: startedExamInfo }); - fireEvent.click(screen.getByText('Show more')); - expect(screen.getByText('Show less')).toBeInTheDocument(); - expect(screen.getByText(/To receive credit for problems/)).toBeInTheDocument(); - }); - - it('toggles back to short instructions when Show less is clicked', () => { - renderTimedExam({ examInfo: startedExamInfo }); - fireEvent.click(screen.getByText('Show more')); - fireEvent.click(screen.getByText('Show less')); - expect(screen.getByText('Show more')).toBeInTheDocument(); - }); - - it('opens end exam confirmation modal when End My Exam is clicked', () => { - renderTimedExam({ examInfo: startedExamInfo }); - fireEvent.click(screen.getByText('End My Exam')); - expect( - screen.getByText(/Are you sure that you want to submit your timed exam/), - ).toBeInTheDocument(); - }); - - it('closes end exam modal when Cancel is clicked', () => { - renderTimedExam({ examInfo: startedExamInfo }); - fireEvent.click(screen.getByText('End My Exam')); - expect(screen.getByText(/Are you sure/)).toBeInTheDocument(); - fireEvent.click(screen.getByText(/No, I want to continue working/)); - expect(screen.queryByText(/Are you sure/)).not.toBeInTheDocument(); - }); - - it('submits exam when confirm end exam is clicked', async () => { - mockUpdateExamAttempt.mockReturnValue({ unwrap: vi.fn().mockResolvedValue({}) }); - mockGetExamInfo.mockResolvedValue({ data: submittedExamInfo }); - - renderTimedExam({ examInfo: startedExamInfo }); - fireEvent.click(screen.getByText('End My Exam')); - - await act(async () => { - fireEvent.click(screen.getByText(/Yes, submit my timed exam/)); - }); - expect(mockUpdateExamAttempt).toHaveBeenCalledWith( - expect.objectContaining({ action: 'submit' }), - ); - }); - - it('handles submit exam error gracefully', async () => { - const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); - mockUpdateExamAttempt.mockReturnValue({ - unwrap: vi.fn().mockRejectedValue(new Error('Submit failed')), - }); - - renderTimedExam({ examInfo: startedExamInfo }); - fireEvent.click(screen.getByText('End My Exam')); - - await act(async () => { - fireEvent.click(screen.getByText(/Yes, submit my timed exam/)); - }); - expect(consoleSpy).toHaveBeenCalledWith('Failed to submit exam:', expect.any(Error)); - consoleSpy.mockRestore(); - }); - - it('shows normal time style (blue) when time is high', () => { - const examWithHighTime = { - ...startedExamInfo, - exam: { - ...startedExamInfo.exam, - attempt: { - ...startedExamInfo.exam.attempt, - time_remaining_seconds: 10000, - low_threshold_sec: 3600, - critically_low_threshold_sec: 1800, - }, - }, - }; - const { container } = renderTimedExam({ examInfo: examWithHighTime }); - expect(container.querySelector('.bg-blue-50')).toBeInTheDocument(); - }); - - it('shows yellow style when time is low', () => { - const examWithLowTime = { - ...startedExamInfo, - exam: { - ...startedExamInfo.exam, - attempt: { - ...startedExamInfo.exam.attempt, - time_remaining_seconds: 2000, - low_threshold_sec: 3600, - critically_low_threshold_sec: 1800, - }, - }, - }; - const { container } = renderTimedExam({ examInfo: examWithLowTime }); - expect(container.querySelector('.bg-yellow-50')).toBeInTheDocument(); - }); - - it('shows red style when time is critically low', () => { - const examWithCriticalTime = { - ...startedExamInfo, - exam: { - ...startedExamInfo.exam, - attempt: { - ...startedExamInfo.exam.attempt, - time_remaining_seconds: 500, - low_threshold_sec: 3600, - critically_low_threshold_sec: 1800, - }, - }, - }; - const { container } = renderTimedExam({ examInfo: examWithCriticalTime }); - expect(container.querySelector('.bg-red-50')).toBeInTheDocument(); - }); - - it('countdown timer decrements time', async () => { - renderTimedExam({ examInfo: startedExamInfo }); - expect(screen.getByText('1:30:00')).toBeInTheDocument(); - - await act(async () => { - vi.advanceTimersByTime(1000); - }); - - expect(screen.getByText('1:29:59')).toBeInTheDocument(); - }); - - it('initializes timer from examInfo when started', () => { - const examWithSpecificTime = { - ...startedExamInfo, - exam: { - ...startedExamInfo.exam, - attempt: { - ...startedExamInfo.exam.attempt, - time_remaining_seconds: 3723, - }, - }, - }; - renderTimedExam({ examInfo: examWithSpecificTime }); - // 3723 seconds = 1:02:03 - expect(screen.getByText('1:02:03')).toBeInTheDocument(); - }); - - it('auto-submits when timer reaches zero', async () => { - const examWithShortTime = { - ...startedExamInfo, - exam: { - ...startedExamInfo.exam, - attempt: { - ...startedExamInfo.exam.attempt, - time_remaining_seconds: 1, - }, - }, - }; - const mockUnwrap = vi.fn().mockResolvedValue({}); - mockUpdateExamAttempt.mockReturnValue({ unwrap: mockUnwrap }); - - renderTimedExam({ examInfo: examWithShortTime }); - - await act(async () => { - vi.advanceTimersByTime(1000); - }); - // Flush all pending promises - await act(async () => { - await Promise.resolve(); - }); - - expect(mockUpdateExamAttempt).toHaveBeenCalledWith( - expect.objectContaining({ action: 'submit' }), - ); - }); - - it('handles auto-submit error gracefully', async () => { - const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); - const examWithShortTime = { - ...startedExamInfo, - exam: { - ...startedExamInfo.exam, - attempt: { - ...startedExamInfo.exam.attempt, - time_remaining_seconds: 1, - }, - }, - }; - const mockUnwrap = vi.fn().mockRejectedValue(new Error('Auto-submit failed')); - mockUpdateExamAttempt.mockReturnValue({ unwrap: mockUnwrap }); - - renderTimedExam({ examInfo: examWithShortTime }); - - await act(async () => { - vi.advanceTimersByTime(1000); - }); - await act(async () => { - await Promise.resolve(); - }); - - expect(consoleSpy).toHaveBeenCalledWith('Failed to auto-submit exam:', expect.any(Error)); - consoleSpy.mockRestore(); - }); - - it('does not run countdown when exam is not started', async () => { - renderTimedExam({ examInfo: noAttemptExamInfo }); - - await act(async () => { - vi.advanceTimersByTime(5000); - }); - - // Timer should not have been running - no timer display shown - expect(screen.queryByText(/End My Exam/)).not.toBeInTheDocument(); - }); - - it('handles updateExamInfo call in updateExamInfo method', async () => { - mockGetExamInfo.mockResolvedValue({ data: submittedExamInfo }); - mockUpdateExamAttempt.mockReturnValue({ unwrap: vi.fn().mockResolvedValue({}) }); - - renderTimedExam({ examInfo: startedExamInfo }); - fireEvent.click(screen.getByText('End My Exam')); - - await act(async () => { - fireEvent.click(screen.getByText(/Yes, submit my timed exam/)); - await Promise.resolve(); - }); - - expect(mockGetExamInfo).toHaveBeenCalled(); - }); - - it('handles updateExamInfo with null data', async () => { - mockGetExamInfo.mockResolvedValue({ data: null }); - mockUpdateExamAttempt.mockReturnValue({ unwrap: vi.fn().mockResolvedValue({}) }); - const mockSetExamInfo = vi.fn(); - - renderTimedExam({ examInfo: startedExamInfo, setExamInfo: mockSetExamInfo }); - fireEvent.click(screen.getByText('End My Exam')); - - await act(async () => { - fireEvent.click(screen.getByText(/Yes, submit my timed exam/)); - await Promise.resolve(); - await Promise.resolve(); - }); - - expect(mockSetExamInfo).toHaveBeenCalledWith(null); - }); - - it('disables start button when starting exam', async () => { - mockStartExam.mockReturnValue({ - unwrap: vi.fn().mockImplementation(() => new Promise(() => {})), - }); - - renderTimedExam({ examInfo: noAttemptExamInfo }); - const startBtn = screen.getByRole('button', { name: /I am ready to start this timed exam/ }); - - act(() => { - fireEvent.click(startBtn); - }); - - // After click, button should be disabled due to isReadyToStart state - expect(startBtn).toBeDisabled(); - }); - - it('renders with default threshold values when not provided', () => { - const examWithNoThresholds = { - ...startedExamInfo, - exam: { - ...startedExamInfo.exam, - attempt: { - ...startedExamInfo.exam.attempt, - time_remaining_seconds: 100, - low_threshold_sec: undefined, - critically_low_threshold_sec: undefined, - }, - }, - }; - const { container } = renderTimedExam({ examInfo: examWithNoThresholds }); - // With default thresholds, 100 seconds should be critically low (< 3600) - expect(container.querySelector('.bg-red-50')).toBeInTheDocument(); - }); - - it('shows hour:min:sec format correctly for zero seconds', () => { - const examWithZeroTime = { - ...startedExamInfo, - exam: { - ...startedExamInfo.exam, - attempt: { - ...startedExamInfo.exam.attempt, - time_remaining_seconds: 3600, - }, - }, - }; - renderTimedExam({ examInfo: examWithZeroTime }); - // 3600 seconds = 1:00:00 - expect(screen.getByText('1:00:00')).toBeInTheDocument(); - }); - - it('handles exam with no active_attempt', () => { - // When both attempt and active_attempt are empty, should show ready-to-start UI - vi.mocked(_.isEmpty).mockReturnValue(true); - const examWithNoActive = { - ...noAttemptExamInfo, - active_attempt: null, - }; - renderTimedExam({ examInfo: examWithNoActive }); - expect(screen.getByText(/Midterm Exam is a Timed Exam/)).toBeInTheDocument(); - }); - - it('shows "Starting exam..." text when isStartingExam is true', async () => { - // @ts-ignore - const { useStartExamMutation } = await import('@iblai/iblai-js/data-layer'); - vi.mocked(useStartExamMutation as any).mockReturnValue([mockStartExam, { isLoading: true }]); - renderTimedExam({ examInfo: noAttemptExamInfo }); - const elements = screen.getAllByText(/Starting exam.../); - expect(elements.length).toBeGreaterThan(0); - }); - - it('shows "Submitting..." text when isSubmittingExam is true', async () => { - // @ts-ignore - const { useUpdateExamAttemptMutation } = await import('@iblai/iblai-js/data-layer'); - vi.mocked(useUpdateExamAttemptMutation as any).mockReturnValue([ - mockUpdateExamAttempt, - { isLoading: true }, - ]); - renderTimedExam({ examInfo: startedExamInfo }); - fireEvent.click(screen.getByText('End My Exam')); - expect(screen.getByText('Submitting...')).toBeInTheDocument(); - }); -}); diff --git a/components/edx-iframe/edx-iframe.tsx b/components/edx-iframe/edx-iframe.tsx deleted file mode 100644 index d5ecd82..0000000 --- a/components/edx-iframe/edx-iframe.tsx +++ /dev/null @@ -1,251 +0,0 @@ -import { EdxIframeContext } from '@/hooks/courses/edx-iframe-context'; -import { useContext, useEffect, useState, useRef } from 'react'; -import { useSearchParams } from 'next/navigation'; -import _ from 'lodash'; -import { useEdxIframe } from '@/hooks/courses/use-edx-iframe'; -import { ChevronRight, Loader2 } from 'lucide-react'; -import { useDebouncedCallback } from 'use-debounce'; -import useCourseNavigator from '@/hooks/courses/useCourseNavigator'; -import { CourseOutlineContext } from '@/contexts/course-outline-context'; -// @ts-ignore -import { useLazyGetExamInfoQuery } from '@iblai/iblai-js/data-layer'; -import { TimedExam } from './timed-exam'; -import { LOCALSTORAGE_KEYS } from '@/constants/storage'; -import { cn } from '@/lib/utils'; - -export const EdxIframe = () => { - const { - courseOutline, - activeTab, - courseID, - setCurrentlyInExamSubsection, - setExamInfo, - examInfo, - iframeUrl, - setIframeUrl, - refresher, - } = useContext(EdxIframeContext); - const { selectLesson, currentUnitID, refetchCourseOutline } = useContext(CourseOutlineContext); - - const searchParams = useSearchParams(); - const [fetchingIframeData, setFetchingIframeData] = useState(true); - const { getIframeURL, findSequentialParent } = useEdxIframe(); - const [iframeLoaded, setIframeLoaded] = useState(false); - const iframeRef = useRef(null); - const { navigator } = useCourseNavigator(courseOutline, currentUnitID || courseID); - - const [getExamInfo] = useLazyGetExamInfoQuery(); - - const handleLoadCourse = useDebouncedCallback(() => { - if (!_.isEmpty(courseOutline)) { - setExamInfo(null); - setCurrentlyInExamSubsection(false); - setFetchingIframeData(true); - if (activeTab === 'course') { - getIframeURL(courseID, courseOutline, async (url) => { - try { - const courseOutlineData = Array.isArray(courseOutline?.children) - ? courseOutline.children[ - navigator?.thirdLevelChildren[navigator?.currentIndex]?.chapterIndex - ] - : courseOutline; - const sequentialParentID = findSequentialParent( - courseOutlineData, - currentUnitID || courseID, - ); - const sequentialParent = courseOutlineData?.children?.find( - (block) => block.id === sequentialParentID, - ); - setCurrentlyInExamSubsection(sequentialParent?.special_exam_info || false); - if (sequentialParent?.special_exam_info) { - const _examInfo = await getExamInfo( - { - course_id: courseID, - content_id: sequentialParent.id, - is_learning_mfe: true, - }, - false, - ); - setExamInfo(_examInfo?.data || null); - } - } catch (error) { - console.error(JSON.stringify(error)); - setCurrentlyInExamSubsection(false); - } - //setIsExamSubsection(url.includes('exam')); - setIframeUrl(url); - setFetchingIframeData(false); - }); - } else { - getIframeURL(courseID, activeTab, (url) => { - setIframeUrl(url); - setFetchingIframeData(false); - }); - } - } - }, 300); - - const navigateEdxURL = (unitID: string) => { - selectLesson(unitID); - }; - - const handlePreviousBtnClick = () => { - const target = navigator.moveToPrevious(); - if (!target) { - return; - } - setTimeout(() => { - navigateEdxURL(target.id); - }, 100); - }; - - const handleNextBtnClick = () => { - const target = navigator.moveToNext(); - if (!target) { - return; - } - setTimeout(() => { - navigateEdxURL(target.id); - }, 100); - }; - - // Store iframeUrl in a ref so we can access it in the message handler - const iframeUrlRef = useRef(iframeUrl); - useEffect(() => { - iframeUrlRef.current = iframeUrl; - }, [iframeUrl]); - - // Store activeTab in a ref so we can access it in the message handler - const activeTabRef = useRef(activeTab); - useEffect(() => { - activeTabRef.current = activeTab; - }, [activeTab]); - - // Listen for ready message from MFE - set up once and always active - // Accept messages from any origin, then validate inside the handler - // This listener is stable and doesn't depend on changing values - useEffect(() => { - const handleMessage = (event: MessageEvent) => { - // Type guard for message data - if (!event.data || typeof event.data !== 'object') { - return; - } - - // Check if this is a ready message from the MFE - if (event.data?.type === 'auth.jwt.ready') { - const currentIframeUrl = iframeUrlRef.current; - const currentActiveTab = activeTabRef.current; - - if (currentIframeUrl) { - try { - const iframeOrigin = new URL(currentIframeUrl).origin; - if (event.origin !== iframeOrigin) { - console.error('[JWT PostMessage] Origin mismatch - rejecting message', { - expected: iframeOrigin, - received: event.origin, - }); - return; - } - } catch (error) { - console.error('[JWT PostMessage] Failed to validate origin:', { - error: error instanceof Error ? error.message : String(error), - iframeUrl: currentIframeUrl, - }); - return; - } - } - - // Send JWT token now that MFE is ready - inline to avoid dependency - const isMFETab = - currentActiveTab === 'progress' || - currentActiveTab === 'dates' || - currentActiveTab === 'forum'; - if (!isMFETab || !iframeRef.current || !currentIframeUrl) return; - - try { - const jwtToken = localStorage.getItem(LOCALSTORAGE_KEYS.EDX_TOKEN_KEY); - if (!jwtToken) return; - - const iframeOrigin = new URL(currentIframeUrl).origin; - const message = { type: 'auth.jwt.token', edx_jwt_token: jwtToken }; - iframeRef.current.contentWindow?.postMessage(message, iframeOrigin); - } catch (error) { - console.error('[JWT PostMessage] Failed to send token in response to ready message:', { - error: error instanceof Error ? error.message : String(error), - iframeUrl: currentIframeUrl, - }); - } - } - }; - - window.addEventListener('message', handleMessage); - - return () => { - window.removeEventListener('message', handleMessage); - }; - }, []); // Empty deps - listener never recreated, reads from refs - - useEffect(() => { - handleLoadCourse(); - }, [courseOutline?.id, searchParams, courseID, activeTab, refresher]); - return ( - <> - {fetchingIframeData ? ( -
- -
- ) : ( -
- {examInfo && } - {(!examInfo || (examInfo?.exam && !_.isEmpty(examInfo?.exam?.attempt))) && ( -