diff --git a/components/__tests__/all-time-perlearner-box.test.tsx b/components/__tests__/all-time-perlearner-box.test.tsx index 6f636eb..3d8e896 100644 --- a/components/__tests__/all-time-perlearner-box.test.tsx +++ b/components/__tests__/all-time-perlearner-box.test.tsx @@ -1,14 +1,23 @@ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, vi } from 'vitest'; import { render, screen } from '@testing-library/react'; import '@testing-library/jest-dom'; + +vi.mock('next/navigation', () => ({ + useParams: () => ({ tenant: 'test-tenant' }), +})); + +vi.mock('@/utils/helpers', () => ({ + getTenant: vi.fn(() => 'test-tenant'), +})); + import { AllTimePerLearnerBox } from '../all-time-perlearner-box'; describe('AllTimePerLearnerBox', () => { const defaultProps = { - total_assessments: 10, total_time_spent: 7200, - total_videos: 5, - course_completions: 3, + courses: 4, + credentials: 2, + skills: 6, }; it('renders without crashing', () => { @@ -16,9 +25,9 @@ describe('AllTimePerLearnerBox', () => { expect(container.firstChild).toBeInTheDocument(); }); - it('renders "All Time" heading', () => { + it('renders "Highlights" heading', () => { render(); - expect(screen.getByText('All Time')).toBeInTheDocument(); + expect(screen.getByText('Highlights')).toBeInTheDocument(); }); it('renders Time Spent label', () => { @@ -26,19 +35,19 @@ describe('AllTimePerLearnerBox', () => { expect(screen.getByText('Time Spent')).toBeInTheDocument(); }); - it('renders Watched Video label', () => { + it('renders Courses label', () => { render(); - expect(screen.getByText('Watched Video')).toBeInTheDocument(); + expect(screen.getByText('Courses')).toBeInTheDocument(); }); - it('renders Assessments label', () => { + it('renders Credentials label', () => { render(); - expect(screen.getByText('Assessments')).toBeInTheDocument(); + expect(screen.getByText('Credentials')).toBeInTheDocument(); }); - it('renders Courses Completions label', () => { + it('renders Skills label', () => { render(); - expect(screen.getByText('Courses Completions')).toBeInTheDocument(); + expect(screen.getByText('Skills')).toBeInTheDocument(); }); it('converts time spent to hours correctly', () => { @@ -47,19 +56,25 @@ describe('AllTimePerLearnerBox', () => { expect(screen.getByText('2 hours')).toBeInTheDocument(); }); - it('displays total videos count', () => { + it('displays courses count as a link to the profile courses page', () => { render(); - expect(screen.getByText('5')).toBeInTheDocument(); + const link = screen.getByRole('link', { name: 'View courses' }); + expect(link).toHaveTextContent('4'); + expect(link).toHaveAttribute('href', '/platform/test-tenant/profile/courses'); }); - it('displays total assessments count', () => { + it('displays credentials count as a link to the profile credentials page', () => { render(); - expect(screen.getByText('10')).toBeInTheDocument(); + const link = screen.getByRole('link', { name: 'View credentials' }); + expect(link).toHaveTextContent('2'); + expect(link).toHaveAttribute('href', '/platform/test-tenant/profile/credentials'); }); - it('displays course completions count', () => { + it('displays skills count as a link to the profile skills page', () => { render(); - expect(screen.getByText('3')).toBeInTheDocument(); + const link = screen.getByRole('link', { name: 'View skills' }); + expect(link).toHaveTextContent('6'); + expect(link).toHaveAttribute('href', '/platform/test-tenant/profile/skills'); }); it('handles zero time spent', () => { @@ -84,14 +99,7 @@ describe('AllTimePerLearnerBox', () => { }); it('handles zero values for all props', () => { - render( - , - ); + render(); expect(screen.getByText('0 hours')).toBeInTheDocument(); // All zeros displayed const zeros = screen.getAllByText('0'); diff --git a/components/__tests__/credentials-list-box.test.tsx b/components/__tests__/credentials-list-box.test.tsx index 7b56e55..02b8a20 100644 --- a/components/__tests__/credentials-list-box.test.tsx +++ b/components/__tests__/credentials-list-box.test.tsx @@ -89,11 +89,9 @@ describe('CredentialsListBox', () => { expect(screen.getByText('Credential B')).toBeInTheDocument(); }); - it('shows empty message when no credentials', () => { + it('does not show an empty message when no credentials', () => { render(); - // DefaultEmptyBox is rendered with message "No credentials yet." - // Since we're not mocking DefaultEmptyBox, it renders the actual component - expect(screen.getByText('No credentials yet.')).toBeInTheDocument(); + expect(screen.queryByText('No credentials yet.')).not.toBeInTheDocument(); }); it('renders correct number of credential items', () => { diff --git a/components/__tests__/profile-sidebar.test.tsx b/components/__tests__/profile-sidebar.test.tsx index e65b978..eb4ca05 100644 --- a/components/__tests__/profile-sidebar.test.tsx +++ b/components/__tests__/profile-sidebar.test.tsx @@ -24,7 +24,9 @@ vi.mock('../credentials-list-box', () => ({ vi.mock('../all-time-perlearner-box', () => ({ AllTimePerLearnerBox: (props: any) => ( -
All Time: {props.total_time_spent}
+
+ All Time: {props.total_time_spent} / {props.courses} / {props.credentials} / {props.skills} +
), })); @@ -56,6 +58,12 @@ const mockUseUserMetadata = vi.fn(() => ({ userMetaDataLoading: false, })); +const mockUseAllTimeStats = vi.fn(() => ({ + courses: 4, + credentials: 2, + skills: 6, +})); + vi.mock('@/hooks/skills/use-latest-skills', () => ({ useLatestSkills: () => mockUseLatestSkills(), })); @@ -72,6 +80,10 @@ vi.mock('@/hooks/users/use-usermetadata', () => ({ useUserMetadata: () => mockUseUserMetadata(), })); +vi.mock('@/hooks/profile/use-all-time-stats', () => ({ + useAllTimeStats: () => mockUseAllTimeStats(), +})); + import { ProfileSidebar } from '../profile-sidebar'; describe('ProfileSidebar', () => { @@ -95,6 +107,11 @@ describe('ProfileSidebar', () => { }, userPerLearnerInfoLoading: false, }); + mockUseAllTimeStats.mockReturnValue({ + courses: 4, + credentials: 2, + skills: 6, + }); }); it('renders without crashing', () => { @@ -143,15 +160,15 @@ describe('ProfileSidebar', () => { it('passes correct props to AllTimePerLearnerBox', () => { render(); - expect(screen.getByText('All Time: 3600')).toBeInTheDocument(); + expect(screen.getByText('All Time: 3600 / 4 / 2 / 6')).toBeInTheDocument(); }); - it('defaults to 0 when perlearner info values are undefined', () => { + it('defaults time spent to 0 when perlearner info values are undefined', () => { mockUsePerLearnerInfoQuery.mockReturnValue({ userPerLearnerInfo: undefined, userPerLearnerInfoLoading: false, } as any); render(); - expect(screen.getByText('All Time: 0')).toBeInTheDocument(); + expect(screen.getByText('All Time: 0 / 4 / 2 / 6')).toBeInTheDocument(); }); }); diff --git a/components/all-time-perlearner-box.tsx b/components/all-time-perlearner-box.tsx index 8756c87..8f0c3f8 100644 --- a/components/all-time-perlearner-box.tsx +++ b/components/all-time-perlearner-box.tsx @@ -1,18 +1,22 @@ -import { Award, Clock, FileText, Play } from 'lucide-react'; +'use client'; +import { Award, BookOpen, Clock, Sparkles } from 'lucide-react'; +import Link from 'next/link'; +import { useTenantParam } from '@/hooks/use-tenant-param'; interface AllTimePerLearnerBoxProps { - total_assessments: number; total_time_spent: number; - total_videos: number; - course_completions: number; + courses: number; + credentials: number; + skills: number; } export const AllTimePerLearnerBox = ({ - total_assessments, total_time_spent, - total_videos, - course_completions, + courses, + credentials, + skills, }: AllTimePerLearnerBoxProps) => { + const tenant = useTenantParam(); const hours = typeof total_time_spent === 'number' && !isNaN(total_time_spent) ? Math.round(total_time_spent / 3600) @@ -20,7 +24,9 @@ export const AllTimePerLearnerBox = ({ return (
-

All Time

+

+ Highlights +

@@ -34,29 +40,47 @@ export const AllTimePerLearnerBox = ({
- +
- Watched Video + Courses
- {total_videos} + + {courses} +
- +
- Assessments + Credentials
- {total_assessments} + + {credentials} +
- +
- Courses Completions + Skills
- {course_completions} + + {skills} +
diff --git a/components/credentials-list-box.tsx b/components/credentials-list-box.tsx index 956b983..5760041 100644 --- a/components/credentials-list-box.tsx +++ b/components/credentials-list-box.tsx @@ -2,7 +2,6 @@ import { Plus } from 'lucide-react'; import { Assertion } from '@iblai/iblai-api'; import { CredentialMiniBox } from './credential-mini-box'; -import { DefaultEmptyBox } from './default-empty-box'; import Link from 'next/link'; import { useTenantParam } from '@/hooks/use-tenant-param'; type CredentialsListBoxProps = { @@ -27,9 +26,6 @@ export function CredentialsListBox({ credentials }: CredentialsListBoxProps) {
- {credentials.length === 0 && ( - - )}
{credentials.map((credential, index) => ( ) : ( )} diff --git a/hooks/profile/__tests__/use-all-time-stats.test.ts b/hooks/profile/__tests__/use-all-time-stats.test.ts new file mode 100644 index 0000000..634933a --- /dev/null +++ b/hooks/profile/__tests__/use-all-time-stats.test.ts @@ -0,0 +1,112 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { renderHook, waitFor } from '@testing-library/react'; +import '@testing-library/jest-dom'; + +vi.mock('@/utils/helpers', () => ({ + getTenant: vi.fn(() => 'test-tenant'), + getUserId: vi.fn(() => 42), + getUserName: vi.fn(() => 'test-user'), +})); + +const mockGetUserReportedSkills = vi.fn(); +const mockGetUserDesiredSkills = vi.fn(); + +vi.mock('@iblai/iblai-js/data-layer', () => ({ + useLazyGetUserReportedSkillsQuery: vi.fn(() => [mockGetUserReportedSkills, { isError: false }]), + useLazyGetUserDesiredSkillsQuery: vi.fn(() => [mockGetUserDesiredSkills, { isError: false }]), +})); + +const mockGetUserCredentials = vi.fn(); +vi.mock('@/services/credentials', () => ({ + useLazyGetUserCredentialsQuery: vi.fn(() => [mockGetUserCredentials, { isError: false }]), +})); + +const mockGetUserEnrolledCourses = vi.fn(); +vi.mock('@/services/courses', () => ({ + useLazyGetUserEnrolledCoursesQuery: vi.fn(() => [mockGetUserEnrolledCourses, { isError: false }]), +})); + +import { useAllTimeStats } from '../use-all-time-stats'; + +describe('useAllTimeStats', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockGetUserReportedSkills.mockResolvedValue({ data: null }); + mockGetUserDesiredSkills.mockResolvedValue({ data: null }); + mockGetUserCredentials.mockResolvedValue({ data: null }); + mockGetUserEnrolledCourses.mockResolvedValue({ data: null }); + }); + + it('returns expected shape with zero defaults', () => { + const { result } = renderHook(() => useAllTimeStats()); + expect(result.current.courses).toBe(0); + expect(result.current.credentials).toBe(0); + expect(result.current.skills).toBe(0); + }); + + it('updates skills count from reported and desired skills', async () => { + mockGetUserReportedSkills.mockResolvedValue({ + data: { skills: [{ name: 'Python' }, { name: 'JavaScript' }] }, + }); + mockGetUserDesiredSkills.mockResolvedValue({ + data: { skills: [{ name: 'React' }] }, + }); + + const { result } = renderHook(() => useAllTimeStats()); + + await waitFor(() => { + expect(result.current.skills).toBe(3); + }); + }); + + it('updates credentials count', async () => { + mockGetUserCredentials.mockResolvedValue({ + data: { data: [{ id: 'cred-1' }, { id: 'cred-2' }] }, + }); + + const { result } = renderHook(() => useAllTimeStats()); + + await waitFor(() => { + expect(result.current.credentials).toBe(2); + }); + }); + + it('updates courses count', async () => { + mockGetUserEnrolledCourses.mockResolvedValue({ + data: { count: 5 }, + }); + + const { result } = renderHook(() => useAllTimeStats()); + + await waitFor(() => { + expect(result.current.courses).toBe(5); + }); + }); + + it('keeps counts at 0 on empty responses', async () => { + const { result } = renderHook(() => useAllTimeStats()); + + await waitFor(() => { + expect(mockGetUserEnrolledCourses).toHaveBeenCalled(); + }); + expect(result.current.courses).toBe(0); + expect(result.current.credentials).toBe(0); + expect(result.current.skills).toBe(0); + }); + + it('keeps counts at 0 when fetches reject', async () => { + mockGetUserReportedSkills.mockRejectedValue(new Error('network')); + mockGetUserDesiredSkills.mockRejectedValue(new Error('network')); + mockGetUserCredentials.mockRejectedValue(new Error('network')); + mockGetUserEnrolledCourses.mockRejectedValue(new Error('network')); + + const { result } = renderHook(() => useAllTimeStats()); + + await waitFor(() => { + expect(mockGetUserEnrolledCourses).toHaveBeenCalled(); + }); + expect(result.current.courses).toBe(0); + expect(result.current.credentials).toBe(0); + expect(result.current.skills).toBe(0); + }); +}); diff --git a/hooks/profile/use-all-time-stats.ts b/hooks/profile/use-all-time-stats.ts new file mode 100644 index 0000000..4b9a507 --- /dev/null +++ b/hooks/profile/use-all-time-stats.ts @@ -0,0 +1,109 @@ +import { getTenant, getUserId, getUserName } from '@/utils/helpers'; +import { useState, useEffect } from 'react'; +import { + // @ts-ignore + useLazyGetUserReportedSkillsQuery, + // @ts-ignore + useLazyGetUserDesiredSkillsQuery, +} from '@iblai/iblai-js/data-layer'; +import _ from 'lodash'; +import { useLazyGetUserCredentialsQuery } from '@/services/credentials'; +import { useLazyGetUserEnrolledCoursesQuery } from '@/services/courses'; + +export const useAllTimeStats = () => { + const [getUserReportedSkills, { isError: isErrorGetUserReportedSkills }] = + useLazyGetUserReportedSkillsQuery(); + const [getUserDesiredSkills, { isError: isErrorGetUserDesiredSkills }] = + useLazyGetUserDesiredSkillsQuery(); + const [getUserCredentials, { isError: isErrorGetUserCredentials }] = + useLazyGetUserCredentialsQuery(); + const [getUserEnrolledCourses, { isError: isErrorGetUserEnrolledCourses }] = + useLazyGetUserEnrolledCoursesQuery(); + + const [courses, setCourses] = useState(0); + const [credentials, setCredentials] = useState(0); + const [skills, setSkills] = useState(0); + + const handleSkillsStats = async () => { + try { + const reportedSkills = await getUserReportedSkills( + [ + { + userId: getUserId(), + username: getUserName(), + }, + ], + true, + ); + let skillsCount = 0; + if (isErrorGetUserDesiredSkills && isErrorGetUserReportedSkills) { + throw new Error(); + } + if (!isErrorGetUserReportedSkills) { + skillsCount = reportedSkills?.data?.skills?.length || 0; + } + const earnedSkills = await getUserDesiredSkills( + [ + { + userId: getUserId(), + username: getUserName(), + }, + ], + true, + ); + if (!isErrorGetUserDesiredSkills) { + skillsCount += earnedSkills?.data?.skills?.length || 0; + } + setSkills(skillsCount); + } catch { + setSkills(0); + } + }; + + const handleCredentialsStats = async () => { + try { + const response = await getUserCredentials( + { + org: getTenant(), + username: getUserName(), + }, + true, + ); + if (isErrorGetUserCredentials || _.isEmpty(response?.data)) { + throw new Error(); + } + setCredentials(response?.data?.data?.length || 0); + } catch { + setCredentials(0); + } + }; + + const handleCoursesStats = async () => { + try { + const response = await getUserEnrolledCourses( + { + username: getUserName(), + }, + true, + ); + if (isErrorGetUserEnrolledCourses || _.isEmpty(response.data)) { + throw new Error(); + } + setCourses(response?.data?.count || 0); + } catch { + setCourses(0); + } + }; + + useEffect(() => { + handleSkillsStats(); + handleCredentialsStats(); + handleCoursesStats(); + }, []); + + return { + courses, + credentials, + skills, + }; +};