@@ -55,10 +58,6 @@ vi.mock('@/components/add-skill-dialog', () => ({
) : null,
-}));
-
-// Mock SkillDetailModal
-vi.mock('@/components/skill-detail-modal', () => ({
SkillDetailModal: ({ skill, onClose, onRatingChange, onDeleteSkill, onConfirm }: any) => (
SkillDetailModal
@@ -76,10 +75,6 @@ vi.mock('@/components/skill-detail-modal', () => ({
),
-}));
-
-// Mock SkillBox
-vi.mock('@/components/skill-box', () => ({
SkillBox: ({ skill, onSkillClick, showRating }: any) => (
Loading...
,
-}));
-
-// Mock DefaultEmptyBox
-vi.mock('@/components/default-empty-box', () => ({
DefaultEmptyBox: ({ message, className }: any) => (
Loading skeletons
@@ -115,8 +98,12 @@ vi.mock('@/components/skeleton-multiplier', () => ({
),
}));
+vi.mock('sonner', () => ({
+ toast: { success: vi.fn(), error: vi.fn() },
+}));
+
import SkillsPage from '../page';
-import { useProfileSkills } from '@/hooks/profile/use-profile-skills';
+import { useProfileSkills } from '@iblai/iblai-js/web-containers';
describe('SkillsPage', () => {
beforeEach(() => {
@@ -138,6 +125,11 @@ describe('SkillsPage', () => {
updatingSkill: false,
deletingSkill: false,
handleSkillsUpdate: mockHandleSkillsUpdate,
+ handleSkillsCreate: mockHandleSkillsCreate,
+ handleFetchAllSkills: mockHandleFetchAllSkills,
+ fetchedSkills: null,
+ isFetchingSkills: false,
+ isFetchingSkillsError: false,
} as any);
});
diff --git a/app/profile/skills/page.tsx b/app/profile/skills/page.tsx
index b629433..676b653 100644
--- a/app/profile/skills/page.tsx
+++ b/app/profile/skills/page.tsx
@@ -2,15 +2,19 @@
import { useState, useRef } from 'react';
import { Search, Plus, ChevronLeft, ChevronRight } from 'lucide-react';
-import { AddSkillDialog } from '@/components/add-skill-dialog';
-import { SkillDetailModal } from '@/components/skill-detail-modal';
-import { useProfileSkills } from '@/hooks/profile/use-profile-skills';
-import { SkillBox } from '@/components/skill-box';
-import { SkeletonSkillBox } from '@/components/skeleton-skill-box';
-import { DefaultEmptyBox } from '@/components/default-empty-box';
+import {
+ AddSkillDialog,
+ DefaultEmptyBox,
+ SkeletonMultiplier,
+ SkeletonSkillBox,
+ SkillBox,
+ SkillDetailModal,
+ useProfileSkills,
+ type UserSkill,
+} from '@iblai/iblai-js/web-containers';
+import { toast } from 'sonner';
import _ from 'lodash';
-import { SkeletonMultiplier } from '@/components/skeleton-multiplier';
-import { UserSkill } from '@/types/skills';
+import type { Skill } from '@iblai/iblai-api';
export default function SkillsPage() {
const {
@@ -30,6 +34,11 @@ export default function SkillsPage() {
updatingSkill,
deletingSkill,
handleSkillsUpdate,
+ handleSkillsCreate,
+ handleFetchAllSkills,
+ fetchedSkills,
+ isFetchingSkills,
+ isFetchingSkillsError,
} = useProfileSkills();
const [searchQuery, setSearchQuery] = useState('');
const [addSkillDialogOpen, setAddSkillDialogOpen] = useState(false);
@@ -355,9 +364,30 @@ export default function SkillsPage() {
open={addSkillDialogOpen}
onOpenChange={setAddSkillDialogOpen}
type={skillTypeToAdd}
- existingSkills={{
- selfReported: selfReportedSkills,
- desired: desiredSkills,
+ fetchedSkills={fetchedSkills as any}
+ isFetchingSkills={isFetchingSkills}
+ isFetchingSkillsError={isFetchingSkillsError}
+ updatingSkill={updatingSkill}
+ onSearch={(query) => handleFetchAllSkills(query)}
+ onAddSkill={async (skill: Skill) => {
+ const targetBucket = skillTypeToAdd === 'desired' ? desiredSkills : selfReportedSkills;
+ const existing = (targetBucket?.skills || []) as any[];
+ const existingLevels = (targetBucket?.data?.level || []) as number[];
+ const newPayload: any = {
+ skills: [...existing, { name: skill.name }],
+ data: {
+ ...(targetBucket?.data || {}),
+ level: [...existingLevels, 0],
+ },
+ type: skillTypeToAdd,
+ };
+ const ok = await handleSkillsCreate(newPayload);
+ if (ok) {
+ toast.success('Skill added successfully');
+ setAddSkillDialogOpen(false);
+ } else {
+ toast.error('Failed to add skill');
+ }
}}
/>
{/* Skill Detail Modal */}
diff --git a/components/__tests__/course-outline-drawer.test.tsx b/components/__tests__/course-outline-drawer.test.tsx
deleted file mode 100644
index 018e673..0000000
--- a/components/__tests__/course-outline-drawer.test.tsx
+++ /dev/null
@@ -1,94 +0,0 @@
-import { describe, it, expect, vi } from 'vitest';
-import { render, screen } from '@testing-library/react';
-import '@testing-library/jest-dom';
-import React from 'react';
-
-vi.mock('@/components/ui/sheet', () => ({
- Sheet: ({ children, open }: any) => (open ?
{children}
: null),
- SheetContent: ({ children }: any) =>
{children}
,
- SheetHeader: ({ children }: any) =>
{children}
,
- SheetTitle: ({ children }: any) =>
{children} ,
-}));
-
-vi.mock('../course-outline', () => ({
- CourseOutline: () =>
Course Outline Content
,
-}));
-
-import { CourseOutlineContext } from '@/contexts/course-outline-context';
-import { CourseOutlineDrawer } from '../course-outline-drawer';
-
-describe('CourseOutlineDrawer', () => {
- const mockSetCourseOutlineDrawerOpen = vi.fn();
-
- const createWrapper = (overrides = {}) => {
- const contextValue = {
- courseOutline: {} as any,
- courseOutlineLoading: false,
- expandedModule: '',
- expandedLessons: [],
- selectLesson: vi.fn(),
- toggleModule: vi.fn(),
- toggleLesson: vi.fn(),
- currentChapter: '',
- currentLesson: '',
- course: { display_name: 'Test Course' } as any,
- courseOutlineDrawerOpen: true,
- setCourseOutlineDrawerOpen: mockSetCourseOutlineDrawerOpen,
- currentUnitID: null,
- refetchCourseOutline: vi.fn(),
- ...overrides,
- };
-
- function CourseOutlineDrawerTestWrapper({ children }: { children: React.ReactNode }) {
- return (
-
- {children}
-
- );
- }
-
- return CourseOutlineDrawerTestWrapper;
- };
-
- it('renders without crashing when open', () => {
- const Wrapper = createWrapper();
- const { container } = render(
, { wrapper: Wrapper });
- expect(container).toBeTruthy();
- });
-
- it('does not render when closed', () => {
- const Wrapper = createWrapper({ courseOutlineDrawerOpen: false });
- const { queryByTestId } = render(
, { wrapper: Wrapper });
- expect(queryByTestId('sheet')).not.toBeInTheDocument();
- });
-
- it('renders the course display name', () => {
- const Wrapper = createWrapper();
- render(
, { wrapper: Wrapper });
- expect(screen.getByText('Test Course')).toBeInTheDocument();
- });
-
- it('renders the CourseOutline component', () => {
- const Wrapper = createWrapper();
- render(
, { wrapper: Wrapper });
- expect(screen.getByTestId('course-outline')).toBeInTheDocument();
- });
-
- it('handles null course gracefully', () => {
- const Wrapper = createWrapper({ course: null });
- const { container } = render(
, { wrapper: Wrapper });
- expect(container).toBeTruthy();
- });
-
- it('renders sheet header', () => {
- const Wrapper = createWrapper();
- render(
, { wrapper: Wrapper });
- expect(screen.getByTestId('sheet-header')).toBeInTheDocument();
- });
-
- it('renders sheet content', () => {
- const Wrapper = createWrapper();
- render(
, { wrapper: Wrapper });
- expect(screen.getByTestId('sheet-content')).toBeInTheDocument();
- });
-});
diff --git a/components/__tests__/course-outline.test.tsx b/components/__tests__/course-outline.test.tsx
deleted file mode 100644
index 2ba2a94..0000000
--- a/components/__tests__/course-outline.test.tsx
+++ /dev/null
@@ -1,358 +0,0 @@
-import { describe, it, expect, vi } from 'vitest';
-import { render, screen, fireEvent } from '@testing-library/react';
-import { CourseOutline } from '../course-outline';
-import { CourseOutlineContext, CourseOutlineContextType } from '@/contexts/course-outline-context';
-import { CourseOutlineChildNode } from '@/types/courses';
-import '@testing-library/jest-dom';
-
-vi.mock('../skeleton-multiplier', () => ({
- SkeletonMultiplier: () =>
,
-}));
-
-vi.mock('../skeleton-course-outline', () => ({
- SkeletonCourseOutline: () =>
,
-}));
-
-const makeNode = (overrides: Partial
= {}): CourseOutlineChildNode => ({
- id: 'node-1',
- block_id: 'block-1',
- type: 'html',
- display_name: 'Node 1',
- ...overrides,
-});
-
-const defaultContext: CourseOutlineContextType = {
- courseOutline: {} as CourseOutlineChildNode,
- courseOutlineLoading: false,
- expandedModule: '',
- expandedLessons: [],
- selectLesson: vi.fn(),
- toggleModule: vi.fn(),
- toggleLesson: vi.fn(),
- currentChapter: '',
- currentLesson: '',
- course: null,
- courseOutlineDrawerOpen: false,
- setCourseOutlineDrawerOpen: vi.fn(),
- currentUnitID: null,
- refetchCourseOutline: vi.fn(),
-};
-
-const renderWithContext = (ctx: Partial = {}) =>
- render(
-
-
- ,
- );
-
-describe('CourseOutline', () => {
- it('renders skeleton when loading', () => {
- renderWithContext({ courseOutlineLoading: true });
- expect(screen.getByTestId('skeleton')).toBeInTheDocument();
- });
-
- it('renders module names', () => {
- const modules = [
- makeNode({ id: 'mod-1', display_name: 'Module 1', children: [] }),
- makeNode({ id: 'mod-2', display_name: 'Module 2', children: [] }),
- ];
- renderWithContext({
- courseOutline: makeNode({ id: 'root', display_name: 'Root', children: modules }),
- });
- expect(screen.getByText('Module 1')).toBeInTheDocument();
- expect(screen.getByText('Module 2')).toBeInTheDocument();
- });
-
- it('shows lessons when module is expanded', () => {
- const modules = [
- makeNode({
- id: 'mod-1',
- display_name: 'Module 1',
- children: [
- makeNode({ id: 'lesson-1', display_name: 'Lesson 1' }),
- makeNode({ id: 'lesson-2', display_name: 'Lesson 2' }),
- ],
- }),
- ];
- renderWithContext({
- courseOutline: makeNode({ id: 'root', display_name: 'Root', children: modules }),
- expandedModule: 'mod-1',
- });
- expect(screen.getByText('Lesson 1')).toBeInTheDocument();
- expect(screen.getByText('Lesson 2')).toBeInTheDocument();
- });
-
- it('calls toggleModule on module click', () => {
- const toggleModule = vi.fn();
- const modules = [makeNode({ id: 'mod-1', display_name: 'Module 1' })];
- renderWithContext({
- courseOutline: makeNode({ id: 'root', display_name: 'Root', children: modules }),
- toggleModule,
- });
- fireEvent.click(screen.getByText('Module 1'));
- expect(toggleModule).toHaveBeenCalledWith('mod-1');
- });
-});
-
-describe('CompletionIcon rendering', () => {
- it('renders empty circle for incomplete leaf node', () => {
- const modules = [
- makeNode({
- id: 'mod-1',
- display_name: 'Module 1',
- children: [makeNode({ id: 'lesson-1', display_name: 'Lesson 1', complete: false })],
- }),
- ];
- const { container } = renderWithContext({
- courseOutline: makeNode({ id: 'root', display_name: 'Root', children: modules }),
- expandedModule: 'mod-1',
- });
- const svgs = container.querySelectorAll('svg');
- // The lesson's completion icon SVG
- // svgs[0] is the module's ChevronRight, svgs[1] is the CompletionIcon
- const lessonSvg = svgs[1];
- expect(lessonSvg).toBeTruthy();
- // Empty circle has gray stroke (#d1d5db)
- const circle = lessonSvg.querySelector('circle');
- expect(circle?.getAttribute('stroke')).toBe('#d1d5db');
- });
-
- it('renders filled amber check for fully complete leaf node', () => {
- const modules = [
- makeNode({
- id: 'mod-1',
- display_name: 'Module 1',
- children: [makeNode({ id: 'lesson-1', display_name: 'Lesson 1', complete: true })],
- }),
- ];
- const { container } = renderWithContext({
- courseOutline: makeNode({ id: 'root', display_name: 'Root', children: modules }),
- expandedModule: 'mod-1',
- });
- const svgs = container.querySelectorAll('svg');
- // svgs[0] is the module's ChevronRight, svgs[1] is the CompletionIcon
- const lessonSvg = svgs[1];
- // Filled circle has amber fill (#f59e0b)
- const circle = lessonSvg.querySelector('circle');
- expect(circle?.getAttribute('fill')).toBe('#f59e0b');
- // Has a checkmark path
- const path = lessonSvg.querySelector('path');
- expect(path).toBeTruthy();
- });
-
- it('renders partial progress for parent with mixed children completion', () => {
- const modules = [
- makeNode({
- id: 'mod-1',
- display_name: 'Module 1',
- children: [
- makeNode({
- id: 'lesson-1',
- display_name: 'Lesson 1',
- children: [
- makeNode({ id: 'sub-1', display_name: 'Sub 1', complete: true }),
- makeNode({ id: 'sub-2', display_name: 'Sub 2', complete: true }),
- makeNode({ id: 'sub-3', display_name: 'Sub 3', complete: false }),
- makeNode({ id: 'sub-4', display_name: 'Sub 4', complete: false }),
- ],
- }),
- ],
- }),
- ];
- const { container } = renderWithContext({
- courseOutline: makeNode({ id: 'root', display_name: 'Root', children: modules }),
- expandedModule: 'mod-1',
- });
- const svgs = container.querySelectorAll('svg');
- // svgs[0] is the module's ChevronRight, svgs[1] is the CompletionIcon
- const lessonSvg = svgs[1];
- // Partial progress has two circles (background + progress arc)
- const circles = lessonSvg.querySelectorAll('circle');
- expect(circles.length).toBe(2);
- // The progress arc has amber stroke
- const progressCircle = circles[1];
- expect(progressCircle.getAttribute('stroke')).toBe('#f59e0b');
- });
-
- it('renders full completion when all children are complete', () => {
- const modules = [
- makeNode({
- id: 'mod-1',
- display_name: 'Module 1',
- children: [
- makeNode({
- id: 'lesson-1',
- display_name: 'Lesson 1',
- children: [
- makeNode({ id: 'sub-1', display_name: 'Sub 1', complete: true }),
- makeNode({ id: 'sub-2', display_name: 'Sub 2', complete: true }),
- ],
- }),
- ],
- }),
- ];
- const { container } = renderWithContext({
- courseOutline: makeNode({ id: 'root', display_name: 'Root', children: modules }),
- expandedModule: 'mod-1',
- });
- const svgs = container.querySelectorAll('svg');
- // svgs[0] is the module's ChevronRight, svgs[1] is the CompletionIcon
- const lessonSvg = svgs[1];
- // Full completion: amber filled circle with checkmark
- const circle = lessonSvg.querySelector('circle');
- expect(circle?.getAttribute('fill')).toBe('#f59e0b');
- const path = lessonSvg.querySelector('path');
- expect(path).toBeTruthy();
- });
-
- it('renders empty circle when no children are complete', () => {
- const modules = [
- makeNode({
- id: 'mod-1',
- display_name: 'Module 1',
- children: [
- makeNode({
- id: 'lesson-1',
- display_name: 'Lesson 1',
- children: [
- makeNode({ id: 'sub-1', display_name: 'Sub 1', complete: false }),
- makeNode({ id: 'sub-2', display_name: 'Sub 2', complete: false }),
- ],
- }),
- ],
- }),
- ];
- const { container } = renderWithContext({
- courseOutline: makeNode({ id: 'root', display_name: 'Root', children: modules }),
- expandedModule: 'mod-1',
- });
- const svgs = container.querySelectorAll('svg');
- // svgs[0] is the module's ChevronRight, svgs[1] is the CompletionIcon
- const lessonSvg = svgs[1];
- // Empty: single circle with gray stroke
- const circles = lessonSvg.querySelectorAll('circle');
- expect(circles.length).toBe(1);
- expect(circles[0].getAttribute('stroke')).toBe('#d1d5db');
- });
-
- it('renders sublessons when a lesson is expanded', () => {
- const modules = [
- makeNode({
- id: 'mod-1',
- display_name: 'Module 1',
- children: [
- makeNode({
- id: 'lesson-1',
- display_name: 'Lesson 1',
- children: [
- makeNode({ id: 'sub-1', display_name: 'Sub 1', complete: false }),
- makeNode({ id: 'sub-2', display_name: 'Sub 2', complete: true }),
- ],
- }),
- ],
- }),
- ];
- renderWithContext({
- courseOutline: makeNode({ id: 'root', display_name: 'Root', children: modules }),
- expandedModule: 'mod-1',
- expandedLessons: ['lesson-1'],
- });
- expect(screen.getByText('Sub 1')).toBeInTheDocument();
- expect(screen.getByText('Sub 2')).toBeInTheDocument();
- });
-
- it('calls selectLesson when a sublesson is clicked', () => {
- const selectLesson = vi.fn();
- const modules = [
- makeNode({
- id: 'mod-1',
- display_name: 'Module 1',
- children: [
- makeNode({
- id: 'lesson-1',
- display_name: 'Lesson 1',
- children: [makeNode({ id: 'sub-1', display_name: 'Sub 1', complete: false })],
- }),
- ],
- }),
- ];
- renderWithContext({
- courseOutline: makeNode({ id: 'root', display_name: 'Root', children: modules }),
- expandedModule: 'mod-1',
- expandedLessons: ['lesson-1'],
- selectLesson,
- });
- fireEvent.click(screen.getByText('Sub 1'));
- expect(selectLesson).toHaveBeenCalledWith('sub-1');
- });
-
- it('highlights the current sublesson', () => {
- const modules = [
- makeNode({
- id: 'mod-1',
- display_name: 'Module 1',
- children: [
- makeNode({
- id: 'lesson-1',
- display_name: 'Lesson 1',
- children: [
- makeNode({ id: 'sub-1', display_name: 'Sub 1', complete: false }),
- makeNode({ id: 'sub-2', display_name: 'Sub 2', complete: false }),
- ],
- }),
- ],
- }),
- ];
- renderWithContext({
- courseOutline: makeNode({ id: 'root', display_name: 'Root', children: modules }),
- expandedModule: 'mod-1',
- expandedLessons: ['lesson-1'],
- currentLesson: 'sub-1',
- });
- const sub1Button = screen.getByText('Sub 1').closest('button');
- expect(sub1Button?.className).toContain('bg-amber-50');
- });
-
- it('calculates recursive completion correctly for deeply nested nodes', () => {
- // Parent with 2 children: one fully complete, one half complete
- // Expected ratio: (1 + 0.5) / 2 = 0.75 => level = round(0.75 * 7) = 5
- const modules = [
- makeNode({
- id: 'mod-1',
- display_name: 'Module 1',
- children: [
- makeNode({
- id: 'lesson-1',
- display_name: 'Lesson 1',
- children: [
- makeNode({
- id: 'sub-1',
- display_name: 'Sub 1',
- complete: true,
- }),
- makeNode({
- id: 'sub-2',
- display_name: 'Sub 2',
- children: [
- makeNode({ id: 'unit-1', display_name: 'Unit 1', complete: true }),
- makeNode({ id: 'unit-2', display_name: 'Unit 2', complete: false }),
- ],
- }),
- ],
- }),
- ],
- }),
- ];
- const { container } = renderWithContext({
- courseOutline: makeNode({ id: 'root', display_name: 'Root', children: modules }),
- expandedModule: 'mod-1',
- });
- const svgs = container.querySelectorAll('svg');
- // svgs[0] is the module's ChevronRight, svgs[1] is the CompletionIcon
- const lessonSvg = svgs[1];
- // Partial progress (level 5 of 7) -> two circles
- const circles = lessonSvg.querySelectorAll('circle');
- expect(circles.length).toBe(2);
- expect(circles[1].getAttribute('stroke')).toBe('#f59e0b');
- });
-});
diff --git a/components/__tests__/skeleton-course-outline.test.tsx b/components/__tests__/skeleton-course-outline.test.tsx
deleted file mode 100644
index 25955b2..0000000
--- a/components/__tests__/skeleton-course-outline.test.tsx
+++ /dev/null
@@ -1,17 +0,0 @@
-import { describe, it, expect, vi } from 'vitest';
-import { render } from '@testing-library/react';
-import '@testing-library/jest-dom';
-import { SkeletonCourseOutline } from '../skeleton-course-outline';
-
-vi.mock('@/components/ui/skeleton', () => ({
- Skeleton: ({ className }: { className?: string }) => (
-
- ),
-}));
-
-describe('SkeletonCourseOutline', () => {
- it('renders without crashing', () => {
- const { container } = render( );
- expect(container.firstChild).toBeInTheDocument();
- });
-});
diff --git a/components/course-outline-drawer.tsx b/components/course-outline-drawer.tsx
deleted file mode 100644
index eb204be..0000000
--- a/components/course-outline-drawer.tsx
+++ /dev/null
@@ -1,24 +0,0 @@
-'use client';
-
-import { CourseOutlineContext } from '@/contexts/course-outline-context';
-import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet';
-import { useContext } from 'react';
-import { CourseOutline } from './course-outline';
-
-export function CourseOutlineDrawer() {
- const { course, courseOutlineDrawerOpen, setCourseOutlineDrawerOpen } =
- useContext(CourseOutlineContext);
-
- return (
-
-
-
-
- {course?.display_name}
-
-
-
-
-
- );
-}
diff --git a/components/course-outline.tsx b/components/course-outline.tsx
deleted file mode 100644
index fcfbcea..0000000
--- a/components/course-outline.tsx
+++ /dev/null
@@ -1,187 +0,0 @@
-import { CourseOutlineContext } from '@/contexts/course-outline-context';
-import { SkeletonMultiplier } from './skeleton-multiplier';
-import { ChevronRight } from 'lucide-react';
-import { useContext } from 'react';
-import { SkeletonCourseOutline } from './skeleton-course-outline';
-import { CourseOutlineChildNode } from '@/types/courses';
-
-const MAX_CHECKMARK_POINT = 7;
-
-const getCompletionRatio = (node: CourseOutlineChildNode): number => {
- if (!Array.isArray(node.children) || node.children.length === 0) {
- return node.complete ? 1 : 0;
- }
- const totalChildren = node.children.length;
- const completedScore = node.children.reduce((acc, child) => acc + getCompletionRatio(child), 0);
- return completedScore / totalChildren;
-};
-
-const getCompletionLevel = (node: CourseOutlineChildNode): number => {
- return Math.round(getCompletionRatio(node) * MAX_CHECKMARK_POINT);
-};
-
-const CompletionIcon = ({ node }: { node: CourseOutlineChildNode }) => {
- const level = getCompletionLevel(node);
- const size = 16;
- const strokeWidth = 2;
- const radius = (size - strokeWidth) / 2;
- const circumference = 2 * Math.PI * radius;
- const progress = level / MAX_CHECKMARK_POINT;
- const dashOffset = circumference * (1 - progress);
-
- if (level === MAX_CHECKMARK_POINT) {
- // Fully complete - filled check circle
- return (
-
-
-
-
- );
- }
-
- if (level === 0) {
- // No progress - empty circle
- return (
-
-
-
- );
- }
-
- // Partial progress - arc circle
- return (
-
-
-
-
- );
-};
-
-export const CourseOutline = () => {
- const {
- courseOutline,
- courseOutlineLoading,
- expandedModule,
- expandedLessons,
- selectLesson,
- toggleModule,
- toggleLesson,
- currentChapter,
- currentLesson,
- } = useContext(CourseOutlineContext);
- return (
-
- {courseOutlineLoading ? (
-
- ) : (
- Array.isArray(courseOutline?.children) &&
- courseOutline.children.map((module) => (
-
-
toggleModule(module.id)}
- className={`flex w-full items-center justify-between p-3 text-left hover:bg-gray-50 ${
- expandedModule === module.id ? 'bg-gray-50' : ''
- }`}
- >
- {module.display_name}
-
-
-
- {expandedModule === module.id && module.children && (
-
- {module.children.map((lesson) => (
-
-
toggleLesson(lesson.id)}
- className={`mb-1 flex w-full items-center justify-between rounded-sm p-2 text-left text-sm ${
- currentChapter === lesson.id
- ? 'bg-amber-50 text-amber-700'
- : 'text-gray-600 hover:bg-gray-50'
- }`}
- >
-
-
-
-
-
{lesson.display_name}
-
- {lesson.children && lesson.children.length > 0 && (
-
- )}
-
-
- {lesson.children &&
- lesson.children.length > 0 &&
- expandedLessons.includes(lesson.id) && (
-
- {lesson.children.map((sublesson) => (
-
selectLesson(sublesson.id)}
- className={`mb-1 flex w-full items-center rounded-sm p-2 text-left text-sm ${
- currentLesson === sublesson.id
- ? 'bg-amber-50 text-amber-700'
- : 'text-gray-600 hover:bg-gray-50'
- }`}
- >
-
-
-
- {sublesson.display_name}
-
- ))}
-
- )}
-
- ))}
-
- )}
-
- ))
- )}
-
- );
-};
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))) && (
-
- )}
- >
- );
-};
diff --git a/components/edx-iframe/timed-exam.tsx b/components/edx-iframe/timed-exam.tsx
deleted file mode 100644
index 8fc9424..0000000
--- a/components/edx-iframe/timed-exam.tsx
+++ /dev/null
@@ -1,326 +0,0 @@
-import { EdxIframeContext } from '@/hooks/courses/edx-iframe-context';
-import { useContext, useState, useEffect } from 'react';
-import { Clock, AlertCircle } from 'lucide-react';
-import _ from 'lodash';
-import {
- // @ts-ignore
- useUpdateExamAttemptMutation,
- // @ts-ignore
- useStartExamMutation,
- // @ts-ignore
- useLazyGetExamInfoQuery,
-} from '@iblai/iblai-js/data-layer';
-
-export const TimedExam = () => {
- const { examInfo, setExamInfo, setRefresher } = useContext(EdxIframeContext);
- const [isReadyToStart, setIsReadyToStart] = useState(false);
- const [timeRemaining, setTimeRemaining] = useState(0);
- const [showFullInstructions, setShowFullInstructions] = useState(false);
- const [showEndExamModal, setShowEndExamModal] = useState(false);
-
- const [updateExamAttempt, { isLoading: isSubmittingExam }] = useUpdateExamAttemptMutation();
- const [startExam, { isLoading: isStartingExam }] = useStartExamMutation();
- const [getExamInfo] = useLazyGetExamInfoQuery();
-
- // Initialize timer when exam is started
- useEffect(() => {
- if (
- examInfo?.exam?.attempt?.attempt_status === 'started' &&
- examInfo?.exam?.attempt?.time_remaining_seconds
- ) {
- setTimeRemaining(Math.floor(examInfo.exam.attempt.time_remaining_seconds));
- }
- }, [examInfo]);
-
- // Countdown timer
- useEffect(() => {
- if (timeRemaining > 0 && examInfo?.exam?.attempt?.attempt_status === 'started') {
- const timer = setInterval(() => {
- setTimeRemaining((prev) => {
- const newTime = prev > 0 ? prev - 1 : 0;
-
- // Auto-submit when timer reaches zero
- if (newTime === 0 && examInfo?.exam?.id) {
- updateExamAttempt({
- attemptID: examInfo.exam.attempt.attempt_id,
- action: 'submit',
- })
- .unwrap()
- .then(() => {
- updateExamInfo();
- setRefresher(new Date());
- })
- .catch((error: any) => {
- console.error('Failed to auto-submit exam:', error);
- });
- }
-
- return newTime;
- });
- }, 1000);
-
- return () => clearInterval(timer);
- }
- }, [
- timeRemaining,
- examInfo?.exam?.attempt?.attempt_status,
- examInfo?.exam?.attempt?.attempt_id,
- updateExamAttempt,
- ]);
-
- if (!examInfo) {
- return null;
- }
-
- // Format time remaining for display
- const formatTimeRemaining = (seconds: number) => {
- const hours = Math.floor(seconds / 3600);
- const minutes = Math.floor((seconds % 3600) / 60);
- const secs = seconds % 60;
-
- if (hours > 0) {
- return `${hours}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
- } else {
- return `${minutes}:${secs.toString().padStart(2, '0')}`;
- }
- };
-
- const { exam } = examInfo;
- const timeLimitHours = Math.floor(exam.time_limit_mins / 60);
- const timeLimitMinutes = exam.time_limit_mins % 60;
-
- const formatTimeLimit = () => {
- if (timeLimitHours > 0 && timeLimitMinutes > 0) {
- return `${timeLimitHours} hour${timeLimitHours > 1 ? 's' : ''} ${timeLimitMinutes} minute${timeLimitMinutes > 1 ? 's' : ''}`;
- } else if (timeLimitHours > 0) {
- return `${timeLimitHours} hour${timeLimitHours > 1 ? 's' : ''}`;
- } else {
- return `${timeLimitMinutes} minute${timeLimitMinutes > 1 ? 's' : ''}`;
- }
- };
-
- const updateExamInfo = async () => {
- const updatedExamInfo = await getExamInfo(
- {
- course_id: examInfo.exam.course_id,
- content_id: examInfo.exam.content_id,
- is_learning_mfe: true,
- },
- false,
- );
- setExamInfo(updatedExamInfo?.data || null);
- };
-
- const handleStartExam = async () => {
- try {
- setIsReadyToStart(true);
-
- if (examInfo?.exam?.id) {
- const formData = new FormData();
- formData.append('exam_id', examInfo.exam.id.toString());
- formData.append('start_clock', 'true');
- await startExam(formData).unwrap();
- await updateExamInfo();
- console.log('Exam started successfully');
- // Optionally refresh exam info to get the updated attempt data
- }
- } catch (error) {
- console.error('Failed to start exam:', error);
- setIsReadyToStart(false); // Reset the state if starting fails
- // Handle error - maybe show an error message to user
- }
- };
-
- const handleEndExam = () => {
- setShowEndExamModal(true);
- };
-
- const handleConfirmEndExam = async () => {
- try {
- if (examInfo?.exam?.id) {
- await updateExamAttempt({
- attemptID: examInfo.exam.attempt.attempt_id,
- action: 'submit',
- }).unwrap();
-
- console.log('Exam submitted successfully');
- setShowEndExamModal(false);
- await updateExamInfo();
- setRefresher(new Date());
- // Optionally refresh exam info or redirect user
- }
- } catch (error) {
- console.error('Failed to submit exam:', error);
- // Handle error - maybe show an error message to user
- }
- };
-
- const handleCancelEndExam = () => {
- setShowEndExamModal(false);
- };
-
- // Show timer UI when exam is started
- if (examInfo?.exam?.attempt?.attempt_status === 'started') {
- const isLowTime = timeRemaining <= (examInfo.exam.attempt.low_threshold_sec || 14400);
- const isCriticalTime =
- timeRemaining <= (examInfo.exam.attempt.critically_low_threshold_sec || 3600);
-
- return (
-
-
-
- {showFullInstructions ? (
- <>
- You are taking "{examInfo.exam.exam_name}" as a timed exam. The timer below shows
- the time remaining in the exam. To receive credit for problems, you must select
- "Submit" for each problem before you select "End My Exam".{' '}
- setShowFullInstructions(false)}
- >
- Show less
-
- >
- ) : (
- <>
- You are taking "{examInfo.exam.exam_name}" as a timed exam.{' '}
- setShowFullInstructions(true)}
- >
- Show more
-
- >
- )}
-
-
-
-
-
-
- {formatTimeRemaining(timeRemaining)}
-
-
-
- End My Exam
-
-
- Warning: Ending the exam will submit all your answers and you cannot return to
- continue.
-
-
-
-
- {/* End Exam Confirmation Modal */}
- {showEndExamModal && (
-
-
-
- Are you sure that you want to submit your timed exam?
-
-
-
- Make sure that you have selected "Submit" for each problem before you submit your
- exam.
-
-
-
- After you submit your exam, your exam will be graded.
-
-
-
-
- {isSubmittingExam ? 'Submitting...' : 'Yes, submit my timed exam.'}
-
-
- No, I want to continue working.
-
-
-
-
- )}
-
- );
- }
-
- if (examInfo?.exam?.attempt?.attempt_status === 'submitted') {
- return null;
- }
-
- // Show "ready to start" UI when no active attempt exists
- if (_.isEmpty(examInfo?.exam?.attempt) || _.isEmpty(examInfo?.active_attempt)) {
- return (
-
-
-
-
-
-
- {examInfo.exam.exam_name} is a Timed Exam ({formatTimeLimit()})
-
-
- This exam has a time limit associated with it. To pass this exam, you must complete
- the problems in the time allowed. After you select I am ready to start this timed
- exam, you will have {formatTimeLimit()} to complete and submit the exam.
-
-
-
- {isStartingExam ? 'Starting exam...' : 'I am ready to start this timed exam.'}
-
-
- Starting this exam will begin a {formatTimeLimit()} timer that cannot be paused.
-
-
-
-
-
-
-
- Can I request additional time to complete my exam?
-
-
-
-
- If you have disabilities, you might be eligible for an additional time allowance on
- timed exams. Ask your course team for information about additional time allowances.
-
-
-
-
- );
- }
-};
diff --git a/components/skeleton-course-outline.tsx b/components/skeleton-course-outline.tsx
deleted file mode 100644
index f1754cb..0000000
--- a/components/skeleton-course-outline.tsx
+++ /dev/null
@@ -1,12 +0,0 @@
-import { Skeleton } from '@/components/ui/skeleton';
-
-export function SkeletonCourseOutline() {
- return (
-
- );
-}
diff --git a/contexts/course-outline-context.tsx b/contexts/course-outline-context.tsx
deleted file mode 100644
index f3325e8..0000000
--- a/contexts/course-outline-context.tsx
+++ /dev/null
@@ -1,38 +0,0 @@
-'use client';
-
-import { createContext } from 'react';
-import { CourseEdxData, CourseOutlineChildNode } from '@/types/courses';
-
-export interface CourseOutlineContextType {
- courseOutline: CourseOutlineChildNode;
- courseOutlineLoading: boolean;
- expandedModule: string;
- expandedLessons: string[];
- selectLesson: (lessonId: string) => void;
- toggleModule: (moduleId: string) => void;
- toggleLesson: (lessonId: string) => void;
- currentChapter: string;
- currentLesson: string;
- course: CourseEdxData | null;
- courseOutlineDrawerOpen: boolean;
- setCourseOutlineDrawerOpen: (open: boolean) => void;
- currentUnitID: string | null;
- refetchCourseOutline: (setLoadingState: boolean) => void;
-}
-
-export const CourseOutlineContext = createContext({
- courseOutline: {} as CourseOutlineChildNode,
- courseOutlineLoading: false,
- expandedModule: '',
- expandedLessons: [],
- selectLesson: () => {},
- toggleModule: () => {},
- toggleLesson: () => {},
- currentChapter: '',
- currentLesson: '',
- course: null,
- courseOutlineDrawerOpen: false,
- setCourseOutlineDrawerOpen: () => {},
- currentUnitID: null,
- refetchCourseOutline: () => {},
-});
diff --git a/hooks/courses/__tests__/use-edx-iframe.test.ts b/hooks/courses/__tests__/use-edx-iframe.test.ts
deleted file mode 100644
index 28b6835..0000000
--- a/hooks/courses/__tests__/use-edx-iframe.test.ts
+++ /dev/null
@@ -1,431 +0,0 @@
-import { describe, it, expect, vi, beforeEach } from 'vitest';
-import { renderHook } from '@testing-library/react';
-
-vi.mock('@/utils/helpers', () => ({
- getUserName: vi.fn(() => 'test-user'),
-}));
-
-vi.mock('@/lib/config', () => ({
- config: {
- urls: {
- lms: vi.fn(() => 'http://lms.example.com'),
- mfe: vi.fn(() => 'http://mfe.example.com'),
- legacyLmsUrl: vi.fn(() => 'http://legacy-lms.example.com'),
- },
- },
-}));
-
-const mockGetEdxSsoAuthToken = vi.fn();
-vi.mock('@/services/edx-sso', () => ({
- useLazyGetEdxSSOTokenQuery: vi.fn(() => [mockGetEdxSsoAuthToken]),
-}));
-
-import { useEdxIframe } from '../use-edx-iframe';
-
-describe('useEdxIframe', () => {
- beforeEach(() => {
- vi.clearAllMocks();
- });
-
- it('returns expected shape', () => {
- const { result } = renderHook(() => useEdxIframe());
- expect(result.current).toHaveProperty('getIframeURL');
- expect(result.current).toHaveProperty('getUnitToIframe');
- expect(result.current).toHaveProperty('findSequentialParent');
- expect(result.current).toHaveProperty('flattenVerticalBlocks');
- expect(result.current).toHaveProperty('getFirstAvailableUnit');
- expect(result.current).toHaveProperty('findLastResumeBlock');
- expect(result.current).toHaveProperty('getParentBlockById');
- expect(result.current).toHaveProperty('getPreviousUnitIframe');
- expect(result.current).toHaveProperty('getNextUnitIframe');
- expect(result.current).toHaveProperty('addBookmarksTab');
- expect(result.current).toHaveProperty('getParentsInfosFromSublessonId');
- });
-
- describe('flattenVerticalBlocks', () => {
- it('returns empty array for null/undefined input', () => {
- const { result } = renderHook(() => useEdxIframe());
- expect(result.current.flattenVerticalBlocks(null)).toEqual([]);
- expect(result.current.flattenVerticalBlocks(undefined)).toEqual([]);
- });
-
- it('flattens vertical blocks from nested data', () => {
- const { result } = renderHook(() => useEdxIframe());
- const data = {
- type: 'course',
- children: [
- {
- type: 'chapter',
- children: [
- {
- type: 'sequential',
- children: [
- {
- type: 'vertical',
- id: 'v1',
- display_name: 'Vertical 1',
- children: [],
- },
- {
- type: 'vertical',
- id: 'v2',
- display_name: 'Vertical 2',
- children: [],
- },
- ],
- },
- ],
- },
- ],
- };
- const flatBlocks = result.current.flattenVerticalBlocks(data);
- expect(flatBlocks).toEqual([
- { id: 'v1', display_name: 'Vertical 1' },
- { id: 'v2', display_name: 'Vertical 2' },
- ]);
- });
-
- it('handles arrays', () => {
- const { result } = renderHook(() => useEdxIframe());
- const data = [
- { type: 'vertical', id: 'v1', display_name: 'V1', children: [] },
- { type: 'vertical', id: 'v2', display_name: 'V2', children: [] },
- ];
- const flatBlocks = result.current.flattenVerticalBlocks(data);
- expect(flatBlocks).toEqual([
- { id: 'v1', display_name: 'V1' },
- { id: 'v2', display_name: 'V2' },
- ]);
- });
- });
-
- describe('findSequentialParent', () => {
- it('finds sequential parent of a vertical block', () => {
- const { result } = renderHook(() => useEdxIframe());
- const data = {
- type: 'course',
- children: [
- {
- type: 'sequential',
- id: 'seq-1',
- children: [{ id: 'v1', type: 'vertical' }],
- },
- ],
- };
- expect(result.current.findSequentialParent(data, 'v1')).toBe('seq-1');
- });
-
- it('returns null when vertical not found', () => {
- const { result } = renderHook(() => useEdxIframe());
- const data = {
- type: 'course',
- children: [
- {
- type: 'sequential',
- id: 'seq-1',
- children: [{ id: 'v1', type: 'vertical' }],
- },
- ],
- };
- expect(result.current.findSequentialParent(data, 'v-nonexistent')).toBeNull();
- });
-
- it('returns null for empty data', () => {
- const { result } = renderHook(() => useEdxIframe());
- expect(result.current.findSequentialParent({ type: 'course' }, 'v1')).toBeNull();
- });
- });
-
- describe('getFirstAvailableUnit', () => {
- it('returns first available unit', () => {
- const { result } = renderHook(() => useEdxIframe());
- const data = {
- children: [
- {
- children: [
- {
- children: [
- { id: 'unit-1', type: 'vertical' },
- { id: 'unit-2', type: 'vertical' },
- ],
- },
- ],
- },
- ],
- };
- expect(result.current.getFirstAvailableUnit(data)).toEqual({
- id: 'unit-1',
- type: 'vertical',
- });
- });
-
- it('returns null when no units are available', () => {
- const { result } = renderHook(() => useEdxIframe());
- const data = {
- children: [{ children: [{ children: [] }] }],
- };
- expect(result.current.getFirstAvailableUnit(data)).toBeNull();
- });
-
- it('returns parent level element on error when course has started', () => {
- const { result } = renderHook(() => useEdxIframe());
- const data = {
- start: '2020-01-01',
- children: [{ children: [{ id: 'seq-1' }] }],
- };
- // This triggers the catch path since children[0]?.children[0]?.children is undefined
- expect(result.current.getFirstAvailableUnit(data)).toEqual({ id: 'seq-1' });
- });
- });
-
- describe('findLastResumeBlock', () => {
- it('finds the last vertical with resume_block true', () => {
- const { result } = renderHook(() => useEdxIframe());
- const data = {
- type: 'course',
- children: [
- {
- type: 'chapter',
- children: [
- { type: 'vertical', id: 'v1', resume_block: true },
- { type: 'vertical', id: 'v2', resume_block: true },
- { type: 'vertical', id: 'v3', resume_block: false },
- ],
- },
- ],
- };
- expect(result.current.findLastResumeBlock(data)).toEqual({
- type: 'vertical',
- id: 'v2',
- resume_block: true,
- });
- });
-
- it('returns null when no resume block exists', () => {
- const { result } = renderHook(() => useEdxIframe());
- const data = {
- type: 'course',
- children: [
- {
- type: 'chapter',
- children: [{ type: 'vertical', id: 'v1', resume_block: false }],
- },
- ],
- };
- expect(result.current.findLastResumeBlock(data)).toBeNull();
- });
- });
-
- describe('getPreviousUnitIframe', () => {
- it('returns previous unit id', () => {
- const { result } = renderHook(() => useEdxIframe());
- const courseData = {
- type: 'course',
- children: [
- {
- type: 'chapter',
- children: [
- {
- type: 'sequential',
- children: [
- { type: 'vertical', id: 'v1', display_name: 'V1', children: [] },
- { type: 'vertical', id: 'v2', display_name: 'V2', children: [] },
- { type: 'vertical', id: 'v3', display_name: 'V3', children: [] },
- ],
- },
- ],
- },
- ],
- };
- expect(result.current.getPreviousUnitIframe('v2', courseData)).toBe('v1');
- });
-
- it('returns null for the first unit', () => {
- const { result } = renderHook(() => useEdxIframe());
- const courseData = {
- type: 'course',
- children: [
- {
- type: 'chapter',
- children: [
- {
- type: 'sequential',
- children: [{ type: 'vertical', id: 'v1', display_name: 'V1', children: [] }],
- },
- ],
- },
- ],
- };
- expect(result.current.getPreviousUnitIframe('v1', courseData)).toBeNull();
- });
-
- it('returns null when id is not found', () => {
- const { result } = renderHook(() => useEdxIframe());
- const courseData = {
- type: 'course',
- children: [
- {
- type: 'chapter',
- children: [
- {
- type: 'sequential',
- children: [{ type: 'vertical', id: 'v1', display_name: 'V1', children: [] }],
- },
- ],
- },
- ],
- };
- expect(result.current.getPreviousUnitIframe('nonexistent', courseData)).toBeNull();
- });
- });
-
- describe('getNextUnitIframe', () => {
- it('returns next unit id', () => {
- const { result } = renderHook(() => useEdxIframe());
- const courseData = {
- type: 'course',
- children: [
- {
- type: 'chapter',
- children: [
- {
- type: 'sequential',
- children: [
- { type: 'vertical', id: 'v1', display_name: 'V1', children: [] },
- { type: 'vertical', id: 'v2', display_name: 'V2', children: [] },
- { type: 'vertical', id: 'v3', display_name: 'V3', children: [] },
- ],
- },
- ],
- },
- ],
- };
- expect(result.current.getNextUnitIframe('v2', courseData)).toBe('v3');
- });
-
- it('returns null for the last unit', () => {
- const { result } = renderHook(() => useEdxIframe());
- const courseData = {
- type: 'course',
- children: [
- {
- type: 'chapter',
- children: [
- {
- type: 'sequential',
- children: [{ type: 'vertical', id: 'v1', display_name: 'V1', children: [] }],
- },
- ],
- },
- ],
- };
- expect(result.current.getNextUnitIframe('v1', courseData)).toBeNull();
- });
-
- it('returns null when id is not found', () => {
- const { result } = renderHook(() => useEdxIframe());
- const courseData = {
- type: 'course',
- children: [
- {
- type: 'chapter',
- children: [
- {
- type: 'sequential',
- children: [{ type: 'vertical', id: 'v1', display_name: 'V1', children: [] }],
- },
- ],
- },
- ],
- };
- expect(result.current.getNextUnitIframe('nonexistent', courseData)).toBeNull();
- });
- });
-
- describe('addBookmarksTab', () => {
- it('adds a bookmarks tab to the tabs array', () => {
- const { result } = renderHook(() => useEdxIframe());
- const tabs: any[] = [];
- result.current.addBookmarksTab(tabs, 'course-123');
- expect(tabs).toHaveLength(1);
- expect(tabs[0]).toEqual({
- tab_id: 'bookmarks',
- title: 'Bookmarks',
- url: expect.stringContaining('/courses/course-123/bookmarks'),
- });
- });
- });
-
- describe('getParentBlockById', () => {
- it('finds a block and returns indices', () => {
- const { result } = renderHook(() => useEdxIframe());
- const blocksArray = [
- {
- id: 'root',
- children: [
- {
- id: 'child-1',
- children: [{ id: 'target-block' }],
- },
- ],
- },
- ];
- const { parentBlock, foundIndices } = result.current.getParentBlockById(
- blocksArray,
- 'target-block',
- );
- expect(parentBlock).toEqual({ id: 'target-block' });
- expect(foundIndices).toEqual([0, 0, 0]);
- });
-
- it('returns null when block is not found', () => {
- const { result } = renderHook(() => useEdxIframe());
- const blocksArray = [{ id: 'root', children: [{ id: 'child-1' }] }];
- const { parentBlock, foundIndices } = result.current.getParentBlockById(
- blocksArray,
- 'nonexistent',
- );
- expect(parentBlock).toBeNull();
- expect(foundIndices).toEqual([]);
- });
- });
-
- describe('getParentsInfosFromSublessonId', () => {
- it('returns module and lesson for a given sublesson id', () => {
- const { result } = renderHook(() => useEdxIframe());
- const modules = [
- {
- id: 'module-1',
- children: [
- {
- id: 'lesson-1',
- children: [{ id: 'sublesson-1' }, { id: 'sublesson-2' }],
- },
- ],
- },
- ];
- const info = result.current.getParentsInfosFromSublessonId(modules, 'sublesson-2');
- expect(info?.module.id).toBe('module-1');
- expect(info?.lesson.id).toBe('lesson-1');
- });
-
- it('returns empty objects when sublesson is not found', () => {
- const { result } = renderHook(() => useEdxIframe());
- const modules = [
- {
- id: 'module-1',
- children: [{ id: 'lesson-1', children: [{ id: 'sublesson-1' }] }],
- },
- ];
- const info = result.current.getParentsInfosFromSublessonId(modules, 'nonexistent');
- expect(info).toEqual({ module: {}, lesson: {} });
- });
-
- it('handles modules without children', () => {
- const { result } = renderHook(() => useEdxIframe());
- const modules = [{ id: 'module-1' }];
- const info = result.current.getParentsInfosFromSublessonId(modules, 'sublesson-1');
- expect(info).toEqual({ module: {}, lesson: {} });
- });
- });
-});
diff --git a/hooks/courses/edx-iframe-context.ts b/hooks/courses/edx-iframe-context.ts
deleted file mode 100644
index 2240fe5..0000000
--- a/hooks/courses/edx-iframe-context.ts
+++ /dev/null
@@ -1,34 +0,0 @@
-import { CourseOutlineChildNode } from '@/types/courses';
-// @ts-ignore
-import { ExamInfo } from '@iblai/iblai-js/data-layer';
-import { createContext } from 'react';
-
-export const EdxIframeContext = createContext<{
- iframeUrl: string;
- setIframeUrl: (url: string) => void;
- courseOutline: CourseOutlineChildNode;
- setActiveTab: (tab: string) => void;
- activeTab: string;
- courseID: string;
- currentlyInExamSubsection: boolean;
- setCurrentlyInExamSubsection: (examSubsection: boolean) => void;
- examInfo: ExamInfo | null;
- setExamInfo: (examInfo: ExamInfo | null) => void;
- refresher: Date | null;
- setRefresher: (refresher: Date) => void;
- //setCourseOutline: (outline:CourseOutlineChildNode[]) => void;
-}>({
- iframeUrl: '',
- setIframeUrl: () => {},
- courseOutline: {} as CourseOutlineChildNode,
- setActiveTab: () => {},
- activeTab: '',
- courseID: '',
- currentlyInExamSubsection: false,
- setCurrentlyInExamSubsection: () => {},
- examInfo: null,
- setExamInfo: () => {},
- refresher: null,
- setRefresher: () => {},
- //setCourseOutline: () => {},
-});
diff --git a/hooks/courses/use-edx-iframe.ts b/hooks/courses/use-edx-iframe.ts
deleted file mode 100644
index 9dfac83..0000000
--- a/hooks/courses/use-edx-iframe.ts
+++ /dev/null
@@ -1,316 +0,0 @@
-import { config } from '@/lib/config';
-import { useLazyGetEdxSSOTokenQuery } from '@/services/edx-sso';
-import { getUserName } from '@/utils/helpers';
-
-export const useEdxIframe = () => {
- const [getEdxSsoAuthToken] = useLazyGetEdxSSOTokenQuery();
-
- function getIframeURL(course_id: string, courseInfo: any, callback: (url: string) => void) {
- //check if the courseInfo is an object, includes : or a string
- if (typeof courseInfo === 'object' || courseInfo.includes(':')) {
- flattenVerticalBlocks(courseInfo);
- let unit = getUnitToIframe(courseInfo);
- addIframeUrl(course_id, unit.id, callback);
- } else {
- addIframeUrl(course_id, courseInfo, callback);
- }
- }
-
- function findSequentialParent(data: any, verticalId: string): string | null {
- // Base case: if the current data block is of type 'sequential' and has children
- if (data.type === 'sequential' && data.children) {
- for (const child of data.children) {
- // Check if the child is the vertical block we are looking for
- if (child.id === verticalId) {
- return data.id; // Return the ID of the sequential block
- }
- // Recursively search deeper if the child is not the vertical block
- const foundId = findSequentialParent(child, verticalId);
- if (foundId) {
- return foundId; // Return the found ID if present
- }
- }
- } else if (data.children) {
- // Continue recursion if the block is not 'sequential' but has children
- for (const child of data.children) {
- const foundId = findSequentialParent(child, verticalId);
- if (foundId) {
- return foundId;
- }
- }
- }
- // Return null if no matching sequential parent is found at this level
- return null;
- }
-
- function flattenVerticalBlocks(data: any) {
- if (!data || typeof data !== 'object') {
- return [];
- }
-
- if (Array.isArray(data)) {
- const result = [];
- for (const item of data) {
- const flattenedItems: any[] = flattenVerticalBlocks(item);
- result.push(...flattenedItems);
- }
- return result;
- }
-
- if (data.type === 'vertical') {
- const block = {
- id: data.id,
- display_name: data.display_name,
- };
-
- const children: any[] = flattenVerticalBlocks(data.children);
- return [block, ...children];
- }
-
- return flattenVerticalBlocks(data.children);
- }
-
- function getFirstAvailableUnit(data: any, maxAttempts = 2) {
- console.log({ data });
- try {
- // Try to find the first available unit within the specified maxAttempts
- for (let attempt = 0; attempt < maxAttempts; attempt++) {
- let element = data.children[0]?.children[0]?.children[attempt];
- if (element) return element; // Return the element if found
- }
- } catch (e) {
- // In case of any error, safely return the upper level element if available
- if (
- data.hasOwnProperty('children') &&
- data.children[0].hasOwnProperty('children') &&
- new Date(data.start) < new Date()
- ) {
- return data.children[0]?.children[0];
- } else {
- /* throw new Error(
- "Course has no content or course has not been started yet"
- ); */
- return null;
- }
- }
-
- return null; // Return null if no element is found after attempts
- }
-
- function findLastResumeBlock(courseData: any) {
- let lastResumeBlock = null;
-
- // Helper function to recursively traverse the tree
- function traverse(node: any) {
- // If the current node has resume_block = true and no children, update the result
- if (node.resume_block && node.type === 'vertical') {
- lastResumeBlock = node;
- }
-
- // Traverse the children
- if (node.children && node.children.length > 0) {
- for (const child of node.children) {
- traverse(child);
- }
- }
- }
-
- // Start traversing from the root node
- traverse(courseData);
-
- return lastResumeBlock;
- }
-
- function getUnitToIframe(courseOutlineData: any) {
- // decide if we have been given an explicit block to iframe
- const courseUrl = new URL(window.location.href);
- if (courseUrl.searchParams.has('unit_id')) {
- const unitId = courseUrl.search.match(/unit_id=([^&]*)/)?.[1];
- if (!unitId) {
- return getFirstAvailableUnit(courseOutlineData);
- }
- return findVerticalById(courseOutlineData, unitId);
- } else {
- let lastResumeBlock = findLastResumeBlock(courseOutlineData);
-
- if (lastResumeBlock) {
- return lastResumeBlock;
- }
- }
-
- return getFirstAvailableUnit(courseOutlineData);
- }
-
- async function addIframeUrl(course_id: string, xblockID: string, callback: any) {
- let url = '';
- let baseLMSIframeURL = `${config.urls.lms()}/xblock/${xblockID}?show_title=0&show_bookmark_button=1&recheck_access=1&view=student_view`;
- try {
- xblockID = xblockID.replace(/^\/|\/$/g, '');
- switch (xblockID) {
- case 'forum':
- url = `${config.urls.mfe()}/discussions/${course_id}/posts`;
- break;
- case 'notes':
- url = `${config.urls.mfe()}/courses/${course_id}/edxnotes`;
- break;
- case 'progress':
- url = `${config.urls.mfe()}/learning/course/${course_id}/progress/`;
- break;
- case 'dates':
- url = `${config.urls.mfe()}/learning/course/${course_id}/dates/`;
- break;
- case 'bookmarks':
- url = `${config.urls.legacyLmsUrl()}/courses/${course_id}/bookmarks/`;
- break;
- case 'instructor':
- baseLMSIframeURL = `${config.urls.lms()}/courses/${course_id}/instructor`;
- default:
- const { data: authSsoToken } = await getEdxSsoAuthToken({
- username: getUserName(),
- redirect_url: baseLMSIframeURL,
- });
- url = `${config.urls.legacyLmsUrl()}/ibl/ai/sso/backend/edx/iframe?sso_auth_token=${
- authSsoToken?.sso_auth_token
- }`;
- break;
- }
- callback(url);
- } catch (error) {
- callback(url);
- }
- }
-
- function findVerticalById(data: any, verticalId: string) {
- // Define a recursive helper function to search through the data
- function search(data: any) {
- for (const item of data) {
- if (item.id === verticalId) {
- return item;
- }
- if (item.children) {
- const result: any = search(item.children);
- if (result) {
- return result;
- }
- }
- }
- return null;
- }
-
- // Call the helper function starting from the top level
- return search(data.children);
- }
-
- const getParentBlockById = (blocksArray: any, targetBlockId: string) => {
- let foundIndices: any[] = [];
-
- const findParentBlock = (currentBlock: any, targetBlockId: string, currentIndices: any[]) => {
- if (currentBlock.id === targetBlockId) {
- foundIndices = currentIndices.slice(); // Copy the current indices
- return currentBlock;
- }
-
- if (currentBlock.children) {
- for (let i = 0; i < currentBlock.children.length; i++) {
- const childBlock = currentBlock.children[i];
- const result: any = findParentBlock(childBlock, targetBlockId, [...currentIndices, i]);
- if (result) {
- return result;
- }
- }
- }
-
- return null;
- };
-
- for (let i = 0; i < blocksArray.length; i++) {
- const rootBlock = blocksArray[i];
- const parentBlock = findParentBlock(rootBlock, targetBlockId, [i]);
- if (parentBlock) {
- return { parentBlock, foundIndices };
- }
- }
-
- return { parentBlock: null, foundIndices };
- };
-
- function getPreviousUnitIframe(suppliedId: string, courseData: any) {
- let idList = flattenVerticalBlocks(courseData);
- const index = idList.findIndex((item) => {
- return item.id === suppliedId;
- });
-
- if (index === -1 || index === 0) {
- // If the suppliedId is not found or it's the first element, return null
- return null;
- }
-
- return idList[index - 1].id;
- }
-
- function getNextUnitIframe(suppliedId: string, courseData: any) {
- let idList = flattenVerticalBlocks(courseData);
- const index = idList.findIndex((item) => item.id === suppliedId);
-
- if (index === -1 || index === idList.length - 1) {
- // If the suppliedId is not found or it's the last element, return null
- return null;
- }
-
- return idList[index + 1].id;
- }
-
- function addBookmarksTab(tabs: any, course_id: string) {
- const newTab = {
- tab_id: 'bookmarks',
- title: 'Bookmarks',
- url: `${process.env.REACT_APP_IBL_LMS_URL}/courses/${course_id}/bookmarks`,
- };
- tabs.push(newTab);
- }
-
- /**
- * Finds the parent module (1st level) and lesson (2nd level) ids from a sublesson (3rd level) id.
- * @param modules - array of modules (courseOutline or courseModules)
- * @param sublessonId - id of the sublesson to search for
- * @returns an object with moduleId and lessonId, or undefined if not found
- */
- function getParentsInfosFromSublessonId(
- modules: any[],
- sublessonId: string,
- ): { module: Record; lesson: Record } | undefined {
- try {
- for (const module of modules) {
- if (!module.children) continue;
- for (const lesson of module.children) {
- if (!lesson.children) continue;
- for (const sublesson of lesson.children) {
- if (sublesson.id === sublessonId) {
- return { module, lesson };
- }
- }
- }
- }
- throw new Error('Sublesson not found');
- } catch (error) {
- return {
- module: {},
- lesson: {},
- };
- }
- }
-
- return {
- getIframeURL,
- getUnitToIframe,
- findSequentialParent,
- flattenVerticalBlocks,
- getFirstAvailableUnit,
- findLastResumeBlock,
- getParentBlockById,
- getPreviousUnitIframe,
- getNextUnitIframe,
- addBookmarksTab,
- getParentsInfosFromSublessonId,
- };
-};
diff --git a/hooks/courses/useCourseNavigator.ts b/hooks/courses/useCourseNavigator.ts
deleted file mode 100644
index 38f033f..0000000
--- a/hooks/courses/useCourseNavigator.ts
+++ /dev/null
@@ -1,100 +0,0 @@
-import { CourseOutlineChildNode } from '@/types/courses';
-
-const useCourseNavigator = (courseData: CourseOutlineChildNode, unitID: string) => {
- // Helper function to flatten third-level children with reference to 1st and 2nd level blocks
- function flattenThirdLevelChildren(data: CourseOutlineChildNode) {
- const thirdLevelChildren: {
- id: any;
- chapterIndex: any;
- sequentialIndex: any;
- thirdLevelIndex: any;
- display_name: any;
- }[] = [];
-
- Array.isArray(data.children) &&
- data.children.forEach((chapter, chapterIndex) => {
- Array.isArray(chapter.children) &&
- chapter.children.forEach((sequential, sequentialIndex) => {
- Array.isArray(sequential.children) &&
- sequential.children.forEach((thirdLevel, thirdLevelIndex) => {
- thirdLevelChildren.push({
- id: thirdLevel.id,
- chapterIndex,
- sequentialIndex,
- thirdLevelIndex,
- display_name: thirdLevel.display_name || `Block ${thirdLevel.id}`,
- });
- });
- });
- });
-
- return thirdLevelChildren;
- }
-
- // Function to move to the next or previous third-level child
- class BlockNavigator {
- thirdLevelChildren: {
- id: any;
- chapterIndex: any;
- sequentialIndex: any;
- thirdLevelIndex: any;
- display_name: any;
- }[];
- currentIndex: number;
- constructor(data: CourseOutlineChildNode, initialId: string | null = null) {
- this.thirdLevelChildren = flattenThirdLevelChildren(data);
- this.currentIndex = this.findInitialIndex(initialId); // Set initial index based on ID or default to 0
- }
-
- // Method to find the initial index based on the given ID
- findInitialIndex(initialId: string | null) {
- if (initialId) {
- const index = this.thirdLevelChildren.findIndex((block) => block.id === initialId);
- return index !== -1 ? index : 0; // If ID not found, start at the first block
- }
- return 0; // Default to the first block if no ID provided
- }
-
- // Get current block
- getCurrentBlock() {
- return this.thirdLevelChildren[this.currentIndex];
- }
-
- // Move to the next block
- moveToNext() {
- if (this.currentIndex < this.thirdLevelChildren.length - 1) {
- this.currentIndex++;
- return this.getCurrentBlock();
- } else {
- console.log('Reached the last block.');
- return null;
- }
- }
-
- // Move to the previous block
- moveToPrevious() {
- if (this.currentIndex > 0) {
- this.currentIndex--;
- return this.getCurrentBlock();
- } else {
- console.log('Reached the first block.');
- return null;
- }
- }
-
- isPreviousHidden() {
- return this.currentIndex === 0;
- }
-
- isNextHidden() {
- return this.currentIndex === this.thirdLevelChildren.length - 1;
- }
- }
- const navigator = new BlockNavigator(courseData, unitID);
-
- return {
- navigator,
- };
-};
-
-export default useCourseNavigator;
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 e4fdffa..3eeb772 100644
--- a/package.json
+++ b/package.json
@@ -25,7 +25,7 @@
"@emotion/is-prop-valid": "1.4.0",
"@hookform/resolvers": "3.10.0",
"@iblai/iblai-api": "4.166.0-ai",
- "@iblai/iblai-js": "1.4.0",
+ "@iblai/iblai-js": "1.4.3",
"@iblai/iblai-web-mentor": "2.0.1",
"@radix-ui/react-accordion": "1.2.2",
"@radix-ui/react-alert-dialog": "1.1.13",
@@ -94,8 +94,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 +115,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)": [
diff --git a/vitest.config.ts b/vitest.config.ts
index 4f3303d..4f6f95f 100644
--- a/vitest.config.ts
+++ b/vitest.config.ts
@@ -9,6 +9,11 @@ export default defineConfig({
setupFiles: ['./__tests__/vitest.setup.ts'],
environment: 'jsdom',
exclude: ['node_modules/**', 'e2e/**', '.opencode/**'],
+ server: {
+ deps: {
+ inline: [/@iblai\//],
+ },
+ },
coverage: {
provider: 'istanbul',
include: [