Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions apps/website/src/__tests__/testUtils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import type { Course } from '../lib/api/db/tables';

export const mockCourse = (overrides: Partial<Course>): Course => ({
certificationBadgeImage: 'badge.png',
certificationDescription: 'Certificate description',
description: 'Course description',
detailsUrl: 'https://example.com',
displayOnCourseHubIndex: true,
durationDescription: '4 weeks',
durationHours: 40,
id: 'course-id',
image: '/images/courses/default.jpg',
slug: 'course-slug',
path: '/courses/course-slug',
shortDescription: 'Short description',
title: 'Course Title',
units: [],
cadence: 'Weekly',
level: 'Beginner',
averageRating: 4.5,
publicLastUpdated: null,
isFeatured: false,
isNew: false,
...overrides,
});
105 changes: 94 additions & 11 deletions apps/website/src/components/Nav/Nav.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,52 @@ import {
render, screen, waitFor, fireEvent,
} from '@testing-library/react';
import { useAuthStore } from '@bluedot/ui';
import useAxios from 'axios-hooks';
import type { GetCoursesResponse } from '../../pages/api/courses';
import { Nav } from './Nav';
import { mockCourse } from '../../__tests__/testUtils';

type UseAxiosResult = ReturnType<typeof useAxios<GetCoursesResponse>>;

// Mock axios-hooks
vi.mock('axios-hooks', () => ({
default: () => [{
data: {
type: 'success',
courses: [
mockCourse({
id: '1',
title: 'Featured Course',
description: 'Featured course description',
path: '/courses/future-of-ai',
image: '/images/courses/featured.jpg',
durationDescription: '4 weeks',
cadence: 'Weekly',
isFeatured: true,
isNew: false,
}),
mockCourse({
id: '2',
title: 'New Course',
description: 'New course description',
path: '/courses/ops',
image: '/images/courses/new.jpg',
durationDescription: '2 weeks',
cadence: 'Daily',
isFeatured: false,
isNew: true,
}),
],
},
loading: false,
error: null,
}, null!, null!] as UseAxiosResult,
}));

const withLoggedInUser = () => {
useAuthStore.setState({
auth: {
email: 'test@example.com',
token: 'mockToken',
expiresAt: Date.now() + 86400_000,
},
Expand All @@ -24,24 +65,66 @@ const withLoggedOutUser = () => {
};

describe('Nav', () => {
const testDropdownLinks = async (container: HTMLElement, variant: 'mobile' | 'desktop') => {
const selector = variant === 'mobile' ? '.mobile-nav-links' : '.nav-links:not(.mobile-nav-links__nav-links)';

// Find the correct button based on variant
const coursesButton = screen.getAllByText('Courses')
.find((btn) => (variant === 'mobile'
? btn.closest('.mobile-nav-links')
: !btn.closest('.mobile-nav-links')));
expect(coursesButton).not.toBeNull();

// Click to open dropdown
fireEvent.click(coursesButton!);

// Wait for and verify course links
await waitFor(() => {
const courseLinks = container.querySelectorAll(`${selector} .nav-dropdown__dropdown-content a`);

// Check specific course links and their URLs
const featuredCourse = Array.from(courseLinks).find((link) => link.textContent?.includes('Featured Course'));
const newCourse = Array.from(courseLinks).find((link) => link.textContent?.includes('New Course'));
const browseAll = Array.from(courseLinks).find((link) => link.textContent === 'Browse all');

expect(featuredCourse).toBeDefined();
expect(featuredCourse?.getAttribute('href')).toBe('/courses/future-of-ai');

expect(newCourse).toBeDefined();
expect(newCourse?.getAttribute('href')).toBe('/courses/ops');

expect(browseAll).toBeDefined();
expect(browseAll?.getAttribute('href')).toBe('/courses');

// Verify "New" tag
const newTags = container.querySelectorAll(`${selector} .tag`);
expect(newTags).toHaveLength(1);
expect(newTags[0]!.textContent).toBe('New');
});
};

test('renders with courses', () => {
const { container } = render(
<Nav
logo="logo.png"
courses={[
{ title: 'Course 1', url: '/course1' },
{ title: 'Course 2', url: '/course2', isNew: true },
]}
/>,
<Nav logo="logo.png" />,
);
expect(container).toMatchSnapshot();
});

test('renders course links in mobile dropdown', async () => {
const { container } = render(<Nav />);
await testDropdownLinks(container, 'mobile');
});

test('renders course links in desktop dropdown', async () => {
const { container } = render(<Nav />);
await testDropdownLinks(container, 'desktop');
});

test('clicking the hamburger button expands the mobile nav drawer', async () => {
withLoggedInUser();

const { container } = render(
<Nav courses={[{ title: 'Course 1', url: '/course1' }]} />,
<Nav />,
);

const hamburgerButton = container.querySelector('.mobile-nav-links__btn');
Expand Down Expand Up @@ -69,7 +152,7 @@ describe('Nav', () => {
withLoggedInUser();

const { container } = render(
<Nav courses={[{ title: 'Course 1', url: '/course1' }]} />,
<Nav />,
);

const profileButton = container.querySelector('.profile-links__btn');
Expand Down Expand Up @@ -97,7 +180,7 @@ describe('Nav', () => {

test('clicking outside the nav closes the drawer', async () => {
const { container } = render(
<Nav courses={[{ title: 'Course 1', url: '/course1' }]} />,
<Nav />,
);

const hamburgerButton = container.querySelector('.mobile-nav-links__btn');
Expand Down Expand Up @@ -130,7 +213,7 @@ describe('Nav', () => {
pathname: mockPathname,
});

render(<Nav courses={[{ title: 'Course 1', url: '/course1' }]} />);
render(<Nav />);

// Check that the href includes the redirect_to parameter on all login buttons
const loginButtons = screen.getAllByText('Login')
Expand Down
57 changes: 35 additions & 22 deletions apps/website/src/components/Nav/_NavLinks.tsx
Original file line number Diff line number Diff line change
@@ -1,25 +1,17 @@
import clsx from 'clsx';
import { FaChevronUp, FaChevronDown } from 'react-icons/fa6';
import { constants, Tag } from '@bluedot/ui';
import { Tag, ProgressDots } from '@bluedot/ui';

import { ROUTES } from '../../lib/routes';
import { A } from '../Text';
import { useCourses } from '../../lib/hooks/useCourses';
import {
DRAWER_CLASSES,
ExpandedSectionsState,
NAV_LINK_CLASSES,
TRANSITION_DURATION_CLASS,
} from './utils';

const NAV_COURSES = [
...(constants?.COURSES.slice(0, 2) || []).map((course) => ({
title: course.title,
url: course.url,
isNew: course.isNew,
})),
{ title: 'Browse all', url: ROUTES.courses.url },
];

const ABOUT = [
{ title: 'Our story', url: ROUTES.about.url },
{ title: 'Careers', url: ROUTES.joinUs.url },
Expand All @@ -43,20 +35,32 @@ export const NavLinks: React.FC<{
className,
isScrolled,
}) => {
const { courses, loading } = useCourses();

const navCourses = loading ? [] : [
...(courses.slice(0, 2) || []).map((course) => ({
title: course.title,
url: course.path,
isNew: course.isNew,
})),
{ title: 'Browse all', url: ROUTES.courses.url },
];

return (
<div className={clsx('nav-links flex gap-9 [&>*]:w-fit', className)}>
<NavDropdown
expandedSections={expandedSections}
isExpanded={expandedSections.explore}
isScrolled={isScrolled}
links={NAV_COURSES}
links={navCourses}
onToggle={() => updateExpandedSections({
about: false,
explore: !expandedSections.explore,
mobileNav: expandedSections.mobileNav,
profile: false,
})}
title="Courses"
loading={loading}
/>
<NavDropdown
expandedSections={expandedSections}
Expand All @@ -70,6 +74,7 @@ export const NavLinks: React.FC<{
profile: false,
})}
title="About"
loading={false}
/>
<A href={ROUTES.blog.url} className={NAV_LINK_CLASSES(isScrolled, isCurrentPath(ROUTES.blog.url))}>Blog</A>
<A href="https://lu.ma/aisafetycommunityevents?utm_source=website&utm_campaign=nav" className={NAV_LINK_CLASSES(isScrolled)}>Events</A>
Expand All @@ -82,11 +87,12 @@ const NavDropdown: React.FC<{
expandedSections: ExpandedSectionsState;
isExpanded: boolean;
isScrolled: boolean;
links: { title: string; url: string; isNew?: boolean }[];
links: { title: string; url: string; isNew?: boolean | null }[];
onToggle: () => void;
title: string;
// Optional
className?: string;
loading: boolean;
}> = ({
expandedSections,
isExpanded,
Expand All @@ -95,6 +101,7 @@ const NavDropdown: React.FC<{
onToggle,
title,
className,
loading,
}) => {
return (
<div className="nav-dropdown">
Expand All @@ -115,16 +122,22 @@ const NavDropdown: React.FC<{
)}
>
<div className={clsx('nav-dropdown__dropdown-content flex flex-col gap-3 w-fit overflow-hidden mx-auto text-pretty', !isExpanded && 'hidden')}>
{links?.map((link) => (
<A key={link.url} href={link.url} className={clsx(NAV_LINK_CLASSES(isScrolled), 'pt-1')}>
{link.title}
{link.isNew && (
<Tag variant="secondary" className="uppercase ml-2 !p-1">
New
</Tag>
)}
</A>
))}
{loading ? (
<div className="py-2">
<ProgressDots />
</div>
) : (
links?.map((link) => (
<A key={link.url} href={link.url} className={clsx(NAV_LINK_CLASSES(isScrolled), 'pt-1')}>
{link.title}
{link.isNew && (
<Tag variant="secondary" className="uppercase ml-2 !p-1">
New
</Tag>
)}
</A>
))
)}
</div>
</div>
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -87,14 +87,14 @@ exports[`Nav > renders with courses 1`] = `
href="/courses/future-of-ai"
tabindex="0"
>
The Future of AI Course
Featured Course
</a>
<a
class="bluedot-a not-prose nav-link nav-link-animation w-fit no-underline pt-1"
href="/courses/ops"
tabindex="0"
>
AI Safety Operations Bootcamp
New Course
<span
class="tag inline-flex items-center px-4 py-2 text-xs font-semibold w-fit !text-bluedot-normal bg-[#E5EDFE] rounded-sm uppercase ml-2 !p-1"
role="status"
Expand Down Expand Up @@ -229,14 +229,14 @@ exports[`Nav > renders with courses 1`] = `
href="/courses/future-of-ai"
tabindex="0"
>
The Future of AI Course
Featured Course
</a>
<a
class="bluedot-a not-prose nav-link nav-link-animation w-fit no-underline pt-1"
href="/courses/ops"
tabindex="0"
>
AI Safety Operations Bootcamp
New Course
<span
class="tag inline-flex items-center px-4 py-2 text-xs font-semibold w-fit !text-bluedot-normal bg-[#E5EDFE] rounded-sm uppercase ml-2 !p-1"
role="status"
Expand Down
Loading