From 3203ea081bda1a9cd27e4e4fb2f5c18331ddb587 Mon Sep 17 00:00:00 2001 From: ANIMASHAUN Michael Date: Thu, 9 Apr 2026 12:48:00 +0100 Subject: [PATCH 1/8] feat: public profile components moved to sdk now used here --- .claude/settings.json | 8 + app/profile/courses/page.tsx | 32 +- app/profile/credentials/page.tsx | 13 +- app/profile/layout.tsx | 2 +- app/profile/page.tsx | 91 ++++-- app/profile/pathways/page.tsx | 340 +++++++++++++++++++--- app/profile/programs/page.tsx | 312 +++++++++++++++++--- app/profile/public/page.tsx | 253 +++++++++++++++- app/profile/skills/page.tsx | 52 +++- hooks/search/use-personnalized-catalog.ts | 45 +-- package.json | 15 +- 11 files changed, 993 insertions(+), 170 deletions(-) create mode 100644 .claude/settings.json diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..cdbd03f --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,8 @@ +{ + "permissions": { + "allow": [ + "Bash(pnpm prettier:*)", + "Bash(pnpm typecheck:*)" + ] + } +} diff --git a/app/profile/courses/page.tsx b/app/profile/courses/page.tsx index b16c48a..0da21f0 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 ReactPaginate from 'react-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/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/page.tsx b/app/profile/pathways/page.tsx index 68f9d37..a1fc853 100644 --- a/app/profile/pathways/page.tsx +++ b/app/profile/pathways/page.tsx @@ -1,28 +1,51 @@ 'use client'; -import { useState } from 'react'; +import { useEffect, useState } from 'react'; import Image from 'next/image'; +import { useRouter } from 'next/navigation'; import { Search, Plus } from 'lucide-react'; -import { PathwayDetailModal } from '@/components/pathway-detail-modal'; -import { CreatePathwayModal } from '@/components/create-pathway-modal'; -import { useProfilePathways } from '@/hooks/profile/use-profile-pathways'; -import { SkeletonMultiplier } from '@/components/skeleton-multiplier'; -import { SkeletonPathwayBox } from '@/components/skeleton-pathway-box'; -import { DefaultEmptyBox } from '@/components/default-empty-box'; -import { PathwayEnrollmentPlus } from '@iblai/iblai-api'; -import { getRandomCourseImage, getTenant } from '@/utils/helpers'; +import { toast } from 'sonner'; +import { + DefaultEmptyBox, + SkeletonMultiplier, + SkeletonPathwayBox, + useProfilePathways, + getRandomCourseImage, +} from '@iblai/iblai-js/web-containers'; +import { + PathwayDetailModal, + CreatePathwayModal, + type CreatePathwayFormData, + type PathwayDetailCourse, +} from '@iblai/iblai-js/web-containers/next'; +import { PathwayCompletionResponse, PathwayEnrollmentPlus } from '@iblai/iblai-api'; +import { getTenant, getUserId, getUserName } from '@/utils/helpers'; import { useTenantMetadata } from '@iblai/iblai-js/web-utils'; +import { config } from '@/lib/config'; +// @ts-ignore +import { + useLazyGetPathwayCompletionQuery, + useLazyGetUserEnrolledPathwaysQuery, + useCreateCatalogPathwaySelfEnrollmentMutation, + useLazyGetPathwayListQuery, + useLazyGetResourceSearchQuery, + useCreateCatalogPathwayMutation, +} from '@iblai/iblai-js/data-layer'; +import { usePersonnalizedCatalog } from '@/hooks/search/use-personnalized-catalog'; +import { slugify } from '@/utils/helpers'; +import { useDebouncedCallback } from 'use-debounce'; export default function PathwaysPage() { const { metadataLoaded, isSkillsAssignmentsFeatureHidden } = useTenantMetadata({ org: getTenant(), }); + const router = useRouter(); const [searchQuery, setSearchQuery] = useState(''); const CATALOG_TAB = 'catalog'; const ASSIGNED_TAB = 'assigned'; const ENROLLED_TAB = 'enrolled'; - const [activeTab, setActiveTab] = useState<'catalog' | 'assigned' | 'enrolled'>(CATALOG_TAB); // "my" or "assigned" - const [selectedPathway, setSelectedPathway] = useState(null); + const [activeTab, setActiveTab] = useState<'catalog' | 'assigned' | 'enrolled'>(CATALOG_TAB); + const [selectedPathway, setSelectedPathway] = useState(null); const [createDialogOpen, setCreateDialogOpen] = useState(false); const { filteredPathways, @@ -35,8 +58,11 @@ export default function PathwaysPage() { } = useProfilePathways({ searchQuery, contentType: activeTab, + lmsUrl: config.urls.lms(), }); + const [randomImage] = useState(() => getRandomCourseImage()); + const handlePathwayTabChange = (tab: 'catalog' | 'assigned' | 'enrolled') => { if (activeTab === tab) return; setActiveTab(tab); @@ -44,13 +70,240 @@ export default function PathwaysPage() { setFilteredPathways([]); }; - const handleCreatePathway = (pathwayData: PathwayEnrollmentPlus) => { - // In a real app, you would send this data to your API - setPathways([...pathways, pathwayData]); - setFilteredPathways([...filteredPathways, pathwayData]); + // ----- PathwayDetailModal wiring ----- + const [getPathwayList] = useLazyGetPathwayListQuery(); + const [getPathwayCompletion] = useLazyGetPathwayCompletionQuery(); + const [getUserEnrolledPathways, { isLoading: isEnrollmentLoading }] = + useLazyGetUserEnrolledPathwaysQuery(); + const [ + createCatalogPathwaySelfEnrollment, + { isError: isEnrollmentError, isSuccess: isEnrollmentSuccess }, + ] = useCreateCatalogPathwaySelfEnrollmentMutation(); + + const [paths, setPaths] = useState([]); + const [pathwayDetailLoading, setPathwayDetailLoading] = useState(false); + const [pathwayCompletion, setPathwayCompletion] = useState( + null, + ); + const [enrollmentStatus, setEnrollmentStatus] = useState(false); + const [isEnrollmentSubmitting, setIsEnrollmentSubmitting] = useState(false); + + // For pathways page we use user-related pathway list (matches old default) + useEffect(() => { + if (!selectedPathway) { + setPaths([]); + setPathwayCompletion(null); + setEnrollmentStatus(false); + return; + } + let cancelled = false; + (async () => { + try { + setPathwayDetailLoading(true); + const resp = await getPathwayList([ + { + pathwayUuid: selectedPathway.pathway_uuid, + username: getUserName(), + }, + ]); + const list = (resp?.data as any) ?? []; + if (Array.isArray(list) && list.length > 0) { + const pathwayCourses: PathwayDetailCourse[] = (list[0]?.path || []).map( + (course: any) => ({ + ...course, + edx_data: { + ...course?.edx_data, + course_image_asset_path: course?.edx_data?.course_image_asset_path + ? config.urls.lms() + course.edx_data.course_image_asset_path + : getRandomCourseImage(), + }, + }), + ); + if (!cancelled) setPaths(pathwayCourses); + } + } catch { + if (!cancelled) { + toast.error('Error fetching pathway details'); + setPaths([]); + } + } finally { + if (!cancelled) setPathwayDetailLoading(false); + } + })(); + (async () => { + try { + const resp = await getPathwayCompletion([ + { + pathwayUuid: selectedPathway.pathway_uuid || '', + username: getUserName(), + }, + ]); + if (!cancelled) setPathwayCompletion((resp.data as PathwayCompletionResponse) || null); + } catch { + if (!cancelled) setPathwayCompletion(null); + } + })(); + (async () => { + try { + const resp = await getUserEnrolledPathways([ + { + username: getUserName(), + pathwayUuid: selectedPathway.pathway_uuid || '', + }, + ]); + if (!cancelled) { + setEnrollmentStatus( + Array.isArray(resp.data) && + resp.data.findIndex( + (pre: any) => pre.active && pre?.pathway_uuid === selectedPathway.pathway_uuid, + ) !== -1, + ); + } + } catch { + if (!cancelled) setEnrollmentStatus(false); + } + })(); + return () => { + cancelled = true; + }; + }, [selectedPathway, getPathwayList, getPathwayCompletion, getUserEnrolledPathways]); + + const handleEnrollIntoPathway = async (pathway: PathwayEnrollmentPlus) => { + if (isEnrollmentSubmitting) return; + try { + setIsEnrollmentSubmitting(true); + await createCatalogPathwaySelfEnrollment([ + { + requestBody: { + // @ts-ignore + pathway_uuid: pathway.pathway_uuid || '', + pathway_key: pathway.platform_key || '', + username: getUserName(), + active: true, + }, + }, + ]); + if (isEnrollmentError) { + throw new Error('Failed to enroll into pathway'); + } + toast.success('Enrolled into pathway successfully'); + setTimeout(() => setIsEnrollmentSubmitting(false), 500); + } catch { + toast.error('Failed to enroll into pathway'); + setIsEnrollmentSubmitting(false); + } }; - const [randomImage] = useState(() => getRandomCourseImage()); + const handleCourseClick = (course: PathwayDetailCourse) => { + if (course?.item_type === 'course') { + router.push(`/courses/${course.course_id}`); + } else if (course?.url) { + window.open(course.url, '_blank'); + } + }; + + // ----- CreatePathwayModal wiring ----- + const [createSearchQuery, setCreateSearchQuery] = useState(''); + const [searchedCourses, setSearchedCourses] = useState([]); + const [searchedResources, setSearchedResources] = useState([]); + const [getResourceSearch, { isLoading: isResourceSearchLoading }] = + useLazyGetResourceSearchQuery(); + const { handleSearch: handleCatalogSearch, isLoading: isCoursesLoading } = + usePersonnalizedCatalog(); + const [createCatalogPathway, { isError: isCreateCatalogPathwayError }] = + useCreateCatalogPathwayMutation(); + + const debouncedSearch = useDebouncedCallback(async (q: string) => { + const resourceSearch = await getResourceSearch([ + { + platformKey: getTenant(), + ...(q.length > 2 ? { name: q } : {}), + }, + ]); + const response = await handleCatalogSearch({ + username: getUserName(), + query: q, + limit: 10, + content: ['courses'], + tenant: getTenant(), + }); + setSearchedResources( + (resourceSearch?.data || []).map((resource: any) => ({ + ...resource, + image: resource?.image || resource?.data?.banner_image || getRandomCourseImage(), + })), + ); + setSearchedCourses( + (response?.data?.results || []).map((result: any) => ({ + ...result, + data: { + ...result.data, + edx_data: { + ...result.data.edx_data, + course_image_asset_path: result.data.edx_data?.course_image_asset_path + ? config.urls.lms() + result.data.edx_data.course_image_asset_path + : getRandomCourseImage(), + }, + }, + })), + ); + }, 500); + + useEffect(() => { + if (createDialogOpen) { + debouncedSearch(createSearchQuery); + } + }, [createDialogOpen, createSearchQuery, debouncedSearch]); + + const handleCreatePathwaySave = async (form: CreatePathwayFormData) => { + const newPathway = { + name: form.name, + path: [ + ...form.selectedCourses.map((courseId) => ({ + item_type: 'course', + course_id: courseId, + })), + ...form.selectedResources.map((resourceId) => ({ + item_type: 'resource', + id: resourceId, + })), + ], + platform_key: getTenant(), + user_id: getUserId(), + username: getUserName(), + visible: false, + pathway_id: slugify(form.name), + data: { + description: form.description, + subject: form.subject, + }, + }; + try { + const response = await createCatalogPathway([ + { + requestBody: newPathway, + userId: getUserId(), + username: getUserName(), + }, + ]); + if (isCreateCatalogPathwayError) { + throw new Error(); + } + toast.success('Pathway created successfully'); + const created = response?.data as PathwayEnrollmentPlus | undefined; + if (created) { + setPathways([...pathways, created]); + setFilteredPathways([...filteredPathways, created]); + } + setCreateDialogOpen(false); + } catch { + toast.error('Failed to create pathway.'); + } + }; + + const selectedPathwayBannerSrc = selectedPathway?.metadata?.banner_image_asset_path + ? config.urls.lms() + selectedPathway.metadata.banner_image_asset_path + : randomImage; return ( <> @@ -153,24 +406,20 @@ export default function PathwaysPage() {

{pathway?.name || ''}

{pathwayCompletions.length > 0 && pathwayCompletions[index] && (
- {pathwayCompletions.length > 0 && pathwayCompletions[index] && ( - <> -
- Progress - - {pathwayCompletions[index].completion_percentage || 0}% - -
-
-
-
- - )} +
+ Progress + + {pathwayCompletions[index].completion_percentage || 0}% + +
+
+
+
)}
@@ -180,7 +429,20 @@ export default function PathwaysPage() {
{/* Pathway Detail Modal */} {selectedPathway && ( - setSelectedPathway(null)} /> + setSelectedPathway(null)} + onEnroll={handleEnrollIntoPathway} + onCourseClick={handleCourseClick} + /> )} {/* Create Pathway Dialog */} @@ -188,8 +450,12 @@ export default function PathwaysPage() { )} 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/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 && ( 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/hooks/search/use-personnalized-catalog.ts b/hooks/search/use-personnalized-catalog.ts index 6c62660..63cb7b4 100644 --- a/hooks/search/use-personnalized-catalog.ts +++ b/hooks/search/use-personnalized-catalog.ts @@ -1,7 +1,7 @@ import { GenericPagination } from '@/types/discover'; // @ts-ignore import { useLazyGetPersonnalizedSearchQuery } from '@iblai/iblai-js/data-layer'; -import { useState } from 'react'; +import { useCallback, useState } from 'react'; export type PersonnalizedCatalogSearchParams = { username: string; @@ -44,26 +44,29 @@ export const usePersonnalizedCatalog = () => { const [pagination, setPagination] = useState(null); - const handleSearch = async (searchParams: PersonnalizedCatalogSearchParams) => { - try { - const response = await getPersonnalizedSearch( - [ - { - ...searchParams, - }, - ], - true, - ); - setPagination({ - count: response?.data?.count || 0, - current_page: response?.data?.current_page || 0, - total_pages: response?.data?.total_pages || 0, - }); - return response; - } catch (error) { - return undefined; - } - }; + const handleSearch = useCallback( + async (searchParams: PersonnalizedCatalogSearchParams) => { + try { + const response = await getPersonnalizedSearch( + [ + { + ...searchParams, + }, + ], + true, + ); + setPagination({ + count: response?.data?.count || 0, + current_page: response?.data?.current_page || 0, + total_pages: response?.data?.total_pages || 0, + }); + return response; + } catch (error) { + return undefined; + } + }, + [getPersonnalizedSearch], + ); return { isLoading, diff --git a/package.json b/package.json index 5c733b5..f6c37f3 100644 --- a/package.json +++ b/package.json @@ -24,9 +24,12 @@ "dependencies": { "@emotion/is-prop-valid": "1.4.0", "@hookform/resolvers": "3.10.0", + "@iblai/data-layer": "file:.yalc/@iblai/data-layer", "@iblai/iblai-api": "4.166.0-ai", - "@iblai/iblai-js": "1.3.5", + "@iblai/iblai-js": "file:.yalc/@iblai/iblai-js", "@iblai/iblai-web-mentor": "2.0.1", + "@iblai/web-containers": "file:.yalc/@iblai/web-containers", + "@iblai/web-utils": "file:.yalc/@iblai/web-utils", "@radix-ui/react-accordion": "1.2.2", "@radix-ui/react-alert-dialog": "1.1.13", "@radix-ui/react-aspect-ratio": "1.1.1", @@ -94,8 +97,11 @@ }, "devDependencies": { "@axe-core/playwright": "4.11.1", + "@commitlint/cli": "20.5.0", + "@commitlint/config-conventional": "20.5.0", "@eslint/eslintrc": "3.3.4", "@playwright/test": "1.58.2", + "@release-it/conventional-changelog": "10.0.6", "@tailwindcss/postcss": "4.1.4", "@testing-library/jest-dom": "6.9.1", "@testing-library/react": "16.3.2", @@ -112,20 +118,17 @@ "eslint-config-next": "15.5.12", "husky": "9.1.7", "jsdom": "26.1.0", + "lint-staged": "16.4.0", "postcss": "8.5.8", "postcss-loader": "8.2.1", "prettier": "3.8.1", "prettier-plugin-tailwindcss": "0.6.11", "release-it": "19.2.4", - "@release-it/conventional-changelog": "10.0.6", "tailwind-scrollbar": "4.0.2", "tailwindcss": "4.2.1", "typescript": "5.9.3", "vite-tsconfig-paths": "5.1.4", - "vitest": "3.2.4", - "lint-staged": "16.4.0", - "@commitlint/cli": "20.5.0", - "@commitlint/config-conventional": "20.5.0" + "vitest": "3.2.4" }, "lint-staged": { "*.ts?(x)": [ From 984498956b983036eec2803c2f5a012ba94a0a32 Mon Sep 17 00:00:00 2001 From: ANIMASHAUN Michael Date: Thu, 9 Apr 2026 20:18:04 +0100 Subject: [PATCH 2/8] feat: course tab content components reused from the sdk --- .claude/settings.json | 3 +- .../[course_id]/bookmarks/page.tsx | 20 +- .../[course_id]/course/page.tsx | 21 +- app/course-content/[course_id]/dates/page.tsx | 20 +- .../[course_id]/discussion/page.tsx | 21 +- .../[course_id]/instructor/page.tsx | 35 +- app/course-content/[course_id]/layout.tsx | 327 ++---------------- app/course-content/[course_id]/loading.tsx | 87 +---- .../[course_id]/progress/page.tsx | 22 +- pnpm-lock.yaml | 48 +-- 10 files changed, 140 insertions(+), 464 deletions(-) diff --git a/.claude/settings.json b/.claude/settings.json index cdbd03f..a4ac0be 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -2,7 +2,8 @@ "permissions": { "allow": [ "Bash(pnpm prettier:*)", - "Bash(pnpm typecheck:*)" + "Bash(pnpm typecheck:*)", + "Bash(grep -rn \"config\" lib/config* config.*)" ] } } 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/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/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/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/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/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/pnpm-lock.yaml b/pnpm-lock.yaml index 9a3b9fa..403530f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -18,8 +18,8 @@ importers: specifier: 4.166.0-ai version: 4.166.0-ai '@iblai/iblai-js': - specifier: 1.3.5 - version: 1.3.5(@axe-core/playwright@4.11.1(playwright-core@1.58.2))(@floating-ui/dom@1.7.6)(@iblai/iblai-api@4.166.0-ai)(@playwright/test@1.58.2)(@reduxjs/toolkit@2.11.2(react-redux@9.2.0(@types/react@19.2.14)(react@19.1.0)(redux@5.0.1))(react@19.1.0))(@tauri-apps/api@2.10.1)(@tiptap/extensions@3.20.1(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4))(@types/react-dom@19.2.1(@types/react@19.2.14))(@types/react@19.2.14)(next@15.3.6(@babel/core@7.29.0)(@playwright/test@1.58.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react-redux@9.2.0(@types/react@19.2.14)(react@19.1.0)(redux@5.0.1))(react@19.1.0)(redux@5.0.1)(sonner@1.7.4(react-dom@19.1.0(react@19.1.0))(react@19.1.0)) + specifier: 1.3.9 + version: 1.3.9(@axe-core/playwright@4.11.1(playwright-core@1.58.2))(@floating-ui/dom@1.7.6)(@iblai/iblai-api@4.166.0-ai)(@playwright/test@1.58.2)(@reduxjs/toolkit@2.11.2(react-redux@9.2.0(@types/react@19.2.14)(react@19.1.0)(redux@5.0.1))(react@19.1.0))(@tauri-apps/api@2.10.1)(@tiptap/extensions@3.20.1(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4))(@types/react-dom@19.2.1(@types/react@19.2.14))(@types/react@19.2.14)(next@15.3.6(@babel/core@7.29.0)(@playwright/test@1.58.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react-redux@9.2.0(@types/react@19.2.14)(react@19.1.0)(redux@5.0.1))(react@19.1.0)(redux@5.0.1)(sonner@1.7.4(react-dom@19.1.0(react@19.1.0))(react@19.1.0)) '@iblai/iblai-web-mentor': specifier: 2.0.1 version: 2.0.1(@babel/core@7.29.0)(@types/babel__core@7.20.5)(rollup@4.59.0)(typescript@5.9.3) @@ -794,8 +794,8 @@ packages: resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} engines: {node: '>=18.18'} - '@iblai/data-layer@1.2.6': - resolution: {integrity: sha512-xoMtJOWyUVQMkhAHEx/NIr8AnqHdlXKpTWmcwqg/dV8aMGPa5p/a2WNwE6wHOCPn/MV9FPJj6RbOCYb+v5oDvA==} + '@iblai/data-layer@1.2.8': + resolution: {integrity: sha512-kh0ws8etjok4I0PUw6Wh+nlWqDaiLXyfB5pY2AdOiJqCvLI3GXenoAJK5Di8ox8AqcubvyqGwsvzuGGEpIiXhA==} engines: {node: 25.3.0} peerDependencies: '@reduxjs/toolkit': 2.7.0 @@ -806,8 +806,8 @@ packages: '@iblai/iblai-api@4.166.0-ai': resolution: {integrity: sha512-dY9Jv+CkXEyTxRsOQFay5YvYe8K2LSQluJJvtssGQV2RbrxPPzONaKfnhcyarDs7Ft35Xqk1fuErjhUwNG3OIg==} - '@iblai/iblai-js@1.3.5': - resolution: {integrity: sha512-6x9mmvA9DQLf9OQ4mDgd9R7RuofXO9ccMExmJtSXT9A+mgFHcXC2QEmhpul6VarIpOFNHD2zFs8wtii31Mb0tA==} + '@iblai/iblai-js@1.3.9': + resolution: {integrity: sha512-QiRfwbIaHW6SGmszKcT7Bm0CVd5UAiOt3NUf5fr9suCuFXiOlgxpw0RvKqQSkjWYnbIZulaOVv/L2S/aoMA7Sg==} engines: {node: '>=20.0.0'} hasBin: true peerDependencies: @@ -830,13 +830,13 @@ packages: '@iblai/iblai-web-mentor@2.0.1': resolution: {integrity: sha512-xmGpaKCrio93pYDWapDwpUzVqNFdZwtwvmaKqUk9l+xpoi0I1JtB5Z7p1I+1s5mSIYsIkbarhFZotRiREy0jaQ==} - '@iblai/mcp@1.2.2': - resolution: {integrity: sha512-FbTRTmFvslaornOEptn+qhEUkg6Dj0CRuPcq6zB7CCP+AwjGBmXFrlFaTIkbatKUKlQxEjgdqEHfpHqal4EO4Q==} + '@iblai/mcp@1.2.3': + resolution: {integrity: sha512-tuaVs2gzR8OYoCHT0gxOB3VSC9LbC8RdWGC/Nd7PVimcK+1HxPWP8mX5GWyzDgn1sJf/Pi5onFGGBHbRzbR7+Q==} engines: {node: '>=20.0.0'} hasBin: true - '@iblai/web-containers@1.2.2': - resolution: {integrity: sha512-cVKJHNjdFDBZo9TJr/tEHhV354+kovrWleBhD8lVHh4w+LNHCg9TriHv16QNJOfZwrPlMa5CfthWXPJlVadBCg==} + '@iblai/web-containers@1.2.6': + resolution: {integrity: sha512-ZUFOMVxeC77r+pevDelWVGgRdjBby83XqTnaaVmOiUwZrqXiO7dwJM4O3msUvOiczsO/Pa9/Z0fHeZRoueMdjg==} engines: {node: 25.3.0} peerDependencies: '@iblai/data-layer': ^1.1.2 @@ -854,8 +854,8 @@ packages: sonner: optional: true - '@iblai/web-utils@1.2.5': - resolution: {integrity: sha512-IZNjs1kL1hh4rQIEfMuSKiMfT6emaf6irvBfb1RqaLG5tELU/DxZJkxQdiMbNpHwzMCfoJvas4/qLad3Xzi9eg==} + '@iblai/web-utils@1.2.7': + resolution: {integrity: sha512-6pRcN1ExboPq9tjspjWwjfmKf23KZXos09Lywv3OYFLEiR6yanE9ppt2HnB6+VGNDNjU8VQMmb1RlGGye5u7xw==} engines: {node: 25.3.0} peerDependencies: '@iblai/data-layer': ^1.1.2 @@ -8156,7 +8156,7 @@ snapshots: '@humanwhocodes/retry@0.4.3': {} - '@iblai/data-layer@1.2.6(@reduxjs/toolkit@2.11.2(react-redux@9.2.0(@types/react@19.2.14)(react@19.1.0)(redux@5.0.1))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react-redux@9.2.0(@types/react@19.2.14)(react@19.1.0)(redux@5.0.1))(react@19.1.0)': + '@iblai/data-layer@1.2.8(@reduxjs/toolkit@2.11.2(react-redux@9.2.0(@types/react@19.2.14)(react@19.1.0)(redux@5.0.1))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react-redux@9.2.0(@types/react@19.2.14)(react@19.1.0)(redux@5.0.1))(react@19.1.0)': dependencies: '@iblai/iblai-api': 4.166.0-ai '@reduxjs/toolkit': 2.11.2(react-redux@9.2.0(@types/react@19.2.14)(react@19.1.0)(redux@5.0.1))(react@19.1.0) @@ -8170,13 +8170,13 @@ snapshots: dependencies: tslib: 2.8.1 - '@iblai/iblai-js@1.3.5(@axe-core/playwright@4.11.1(playwright-core@1.58.2))(@floating-ui/dom@1.7.6)(@iblai/iblai-api@4.166.0-ai)(@playwright/test@1.58.2)(@reduxjs/toolkit@2.11.2(react-redux@9.2.0(@types/react@19.2.14)(react@19.1.0)(redux@5.0.1))(react@19.1.0))(@tauri-apps/api@2.10.1)(@tiptap/extensions@3.20.1(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4))(@types/react-dom@19.2.1(@types/react@19.2.14))(@types/react@19.2.14)(next@15.3.6(@babel/core@7.29.0)(@playwright/test@1.58.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react-redux@9.2.0(@types/react@19.2.14)(react@19.1.0)(redux@5.0.1))(react@19.1.0)(redux@5.0.1)(sonner@1.7.4(react-dom@19.1.0(react@19.1.0))(react@19.1.0))': + '@iblai/iblai-js@1.3.9(@axe-core/playwright@4.11.1(playwright-core@1.58.2))(@floating-ui/dom@1.7.6)(@iblai/iblai-api@4.166.0-ai)(@playwright/test@1.58.2)(@reduxjs/toolkit@2.11.2(react-redux@9.2.0(@types/react@19.2.14)(react@19.1.0)(redux@5.0.1))(react@19.1.0))(@tauri-apps/api@2.10.1)(@tiptap/extensions@3.20.1(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4))(@types/react-dom@19.2.1(@types/react@19.2.14))(@types/react@19.2.14)(next@15.3.6(@babel/core@7.29.0)(@playwright/test@1.58.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react-redux@9.2.0(@types/react@19.2.14)(react@19.1.0)(redux@5.0.1))(react@19.1.0)(redux@5.0.1)(sonner@1.7.4(react-dom@19.1.0(react@19.1.0))(react@19.1.0))': dependencies: - '@iblai/data-layer': 1.2.6(@reduxjs/toolkit@2.11.2(react-redux@9.2.0(@types/react@19.2.14)(react@19.1.0)(redux@5.0.1))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react-redux@9.2.0(@types/react@19.2.14)(react@19.1.0)(redux@5.0.1))(react@19.1.0) + '@iblai/data-layer': 1.2.8(@reduxjs/toolkit@2.11.2(react-redux@9.2.0(@types/react@19.2.14)(react@19.1.0)(redux@5.0.1))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react-redux@9.2.0(@types/react@19.2.14)(react@19.1.0)(redux@5.0.1))(react@19.1.0) '@iblai/iblai-api': 4.166.0-ai - '@iblai/mcp': 1.2.2 - '@iblai/web-containers': 1.2.2(109d9ca7b08c2f32c2ff9c53f8669ff6) - '@iblai/web-utils': 1.2.5(@iblai/data-layer@1.2.6(@reduxjs/toolkit@2.11.2(react-redux@9.2.0(@types/react@19.2.14)(react@19.1.0)(redux@5.0.1))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react-redux@9.2.0(@types/react@19.2.14)(react@19.1.0)(redux@5.0.1))(react@19.1.0))(@iblai/iblai-api@4.166.0-ai)(@tauri-apps/api@2.10.1)(@types/react@19.2.14)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(redux@5.0.1) + '@iblai/mcp': 1.2.3 + '@iblai/web-containers': 1.2.6(b8644144625d90a2d9c984d8fca1a589) + '@iblai/web-utils': 1.2.7(@iblai/data-layer@1.2.8(@reduxjs/toolkit@2.11.2(react-redux@9.2.0(@types/react@19.2.14)(react@19.1.0)(redux@5.0.1))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react-redux@9.2.0(@types/react@19.2.14)(react@19.1.0)(redux@5.0.1))(react@19.1.0))(@iblai/iblai-api@4.166.0-ai)(@tauri-apps/api@2.10.1)(@types/react@19.2.14)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(redux@5.0.1) '@reduxjs/toolkit': 2.11.2(react-redux@9.2.0(@types/react@19.2.14)(react@19.1.0)(redux@5.0.1))(react@19.1.0) axios: 1.13.6 dotenv: 16.6.1 @@ -8213,7 +8213,7 @@ snapshots: - supports-color - typescript - '@iblai/mcp@1.2.2': + '@iblai/mcp@1.2.3': dependencies: '@modelcontextprotocol/sdk': 1.27.1(zod@4.3.6) zod: 4.3.6 @@ -8221,11 +8221,11 @@ snapshots: - '@cfworker/json-schema' - supports-color - '@iblai/web-containers@1.2.2(109d9ca7b08c2f32c2ff9c53f8669ff6)': + '@iblai/web-containers@1.2.6(b8644144625d90a2d9c984d8fca1a589)': dependencies: - '@iblai/data-layer': 1.2.6(@reduxjs/toolkit@2.11.2(react-redux@9.2.0(@types/react@19.2.14)(react@19.1.0)(redux@5.0.1))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react-redux@9.2.0(@types/react@19.2.14)(react@19.1.0)(redux@5.0.1))(react@19.1.0) + '@iblai/data-layer': 1.2.8(@reduxjs/toolkit@2.11.2(react-redux@9.2.0(@types/react@19.2.14)(react@19.1.0)(redux@5.0.1))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react-redux@9.2.0(@types/react@19.2.14)(react@19.1.0)(redux@5.0.1))(react@19.1.0) '@iblai/iblai-api': 4.166.0-ai - '@iblai/web-utils': 1.2.5(@iblai/data-layer@1.2.6(@reduxjs/toolkit@2.11.2(react-redux@9.2.0(@types/react@19.2.14)(react@19.1.0)(redux@5.0.1))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react-redux@9.2.0(@types/react@19.2.14)(react@19.1.0)(redux@5.0.1))(react@19.1.0))(@iblai/iblai-api@4.166.0-ai)(@tauri-apps/api@2.10.1)(@types/react@19.2.14)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(redux@5.0.1) + '@iblai/web-utils': 1.2.7(@iblai/data-layer@1.2.8(@reduxjs/toolkit@2.11.2(react-redux@9.2.0(@types/react@19.2.14)(react@19.1.0)(redux@5.0.1))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react-redux@9.2.0(@types/react@19.2.14)(react@19.1.0)(redux@5.0.1))(react@19.1.0))(@iblai/iblai-api@4.166.0-ai)(@tauri-apps/api@2.10.1)(@types/react@19.2.14)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(redux@5.0.1) '@radix-ui/react-accordion': 1.2.8(@types/react-dom@19.2.1(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@radix-ui/react-avatar': 1.1.7(@types/react-dom@19.2.1(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@radix-ui/react-checkbox': 1.2.3(@types/react-dom@19.2.1(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) @@ -8295,9 +8295,9 @@ snapshots: - supports-color - vinxi - '@iblai/web-utils@1.2.5(@iblai/data-layer@1.2.6(@reduxjs/toolkit@2.11.2(react-redux@9.2.0(@types/react@19.2.14)(react@19.1.0)(redux@5.0.1))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react-redux@9.2.0(@types/react@19.2.14)(react@19.1.0)(redux@5.0.1))(react@19.1.0))(@iblai/iblai-api@4.166.0-ai)(@tauri-apps/api@2.10.1)(@types/react@19.2.14)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(redux@5.0.1)': + '@iblai/web-utils@1.2.7(@iblai/data-layer@1.2.8(@reduxjs/toolkit@2.11.2(react-redux@9.2.0(@types/react@19.2.14)(react@19.1.0)(redux@5.0.1))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react-redux@9.2.0(@types/react@19.2.14)(react@19.1.0)(redux@5.0.1))(react@19.1.0))(@iblai/iblai-api@4.166.0-ai)(@tauri-apps/api@2.10.1)(@types/react@19.2.14)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(redux@5.0.1)': dependencies: - '@iblai/data-layer': 1.2.6(@reduxjs/toolkit@2.11.2(react-redux@9.2.0(@types/react@19.2.14)(react@19.1.0)(redux@5.0.1))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react-redux@9.2.0(@types/react@19.2.14)(react@19.1.0)(redux@5.0.1))(react@19.1.0) + '@iblai/data-layer': 1.2.8(@reduxjs/toolkit@2.11.2(react-redux@9.2.0(@types/react@19.2.14)(react@19.1.0)(redux@5.0.1))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react-redux@9.2.0(@types/react@19.2.14)(react@19.1.0)(redux@5.0.1))(react@19.1.0) '@iblai/iblai-api': 4.166.0-ai '@reduxjs/toolkit': 2.11.2(react-redux@9.2.0(@types/react@19.2.14)(react@19.1.0)(redux@5.0.1))(react@19.1.0) axios: 1.13.6 From 9d2ad3e4bd3c1e35632d298ba944b51953ee643c Mon Sep 17 00:00:00 2001 From: ANIMASHAUN Michael Date: Tue, 14 Apr 2026 00:40:53 +0100 Subject: [PATCH 3/8] feat: course tab content components removed from the repo --- .../__tests__/course-outline-drawer.test.tsx | 94 --- components/__tests__/course-outline.test.tsx | 358 ----------- .../skeleton-course-outline.test.tsx | 17 - components/course-outline-drawer.tsx | 24 - components/course-outline.tsx | 187 ------ .../edx-iframe/__tests__/edx-iframe.test.tsx | 299 --------- .../edx-iframe/__tests__/timed-exam.test.tsx | 588 ------------------ components/edx-iframe/edx-iframe.tsx | 251 -------- components/edx-iframe/timed-exam.tsx | 326 ---------- components/skeleton-course-outline.tsx | 12 - contexts/course-outline-context.tsx | 38 -- .../courses/__tests__/use-edx-iframe.test.ts | 431 ------------- hooks/courses/edx-iframe-context.ts | 34 - hooks/courses/use-edx-iframe.ts | 316 ---------- hooks/courses/useCourseNavigator.ts | 100 --- 15 files changed, 3075 deletions(-) delete mode 100644 components/__tests__/course-outline-drawer.test.tsx delete mode 100644 components/__tests__/course-outline.test.tsx delete mode 100644 components/__tests__/skeleton-course-outline.test.tsx delete mode 100644 components/course-outline-drawer.tsx delete mode 100644 components/course-outline.tsx delete mode 100644 components/edx-iframe/__tests__/edx-iframe.test.tsx delete mode 100644 components/edx-iframe/__tests__/timed-exam.test.tsx delete mode 100644 components/edx-iframe/edx-iframe.tsx delete mode 100644 components/edx-iframe/timed-exam.tsx delete mode 100644 components/skeleton-course-outline.tsx delete mode 100644 contexts/course-outline-context.tsx delete mode 100644 hooks/courses/__tests__/use-edx-iframe.test.ts delete mode 100644 hooks/courses/edx-iframe-context.ts delete mode 100644 hooks/courses/use-edx-iframe.ts delete mode 100644 hooks/courses/useCourseNavigator.ts 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))) && ( -