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
218 changes: 182 additions & 36 deletions src/components/hero-ctas.test.tsx
Original file line number Diff line number Diff line change
@@ -1,56 +1,202 @@
// @vitest-environment jsdom
import type { ReactNode } from 'react';
import { cleanup, render, screen } from '@testing-library/react';
import type { ComponentProps } from 'react';
import { cleanup, render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { NuqsTestingAdapter } from 'nuqs/adapters/testing';
import type { UrlUpdateEvent } from 'nuqs/adapters/testing';

import { ActiveFiltersSearch } from '@/features/filters/components/active-filters/active-filter-search';
import { HeroCtas } from './hero-ctas';

afterEach(() => {
cleanup();
});
class ResizeObserverMock {
observe() {}
unobserve() {}
disconnect() {}
}
vi.stubGlobal('ResizeObserver', ResizeObserverMock);

vi.mock('next/link', () => ({
default: ({
children,
href,
prefetch: _,
...props
}: {
children: ReactNode;
href: string;
}) => (
// eslint-disable-next-line @next/next/no-html-link-for-pages
<a href={href} {...props}>
{children}
</a>
),
}: ComponentProps<'a'> & { prefetch?: boolean }) => <a {...props} />,
}));

vi.mock('@/lib/analytics', () => ({
GA_EVENT: { HERO_CTA_CLICKED: 'hero_cta_clicked' },
trackEvent: vi.fn(),
vi.mock('@bprogress/next', () => ({ useProgress: () => ({ start: vi.fn() }) }));
vi.mock('@/lib/analytics', () => ({ GA_EVENT: {}, trackEvent: vi.fn() }));
vi.mock('@/lib/env/client', () => ({
clientEnv: { MW_URL: 'https://middleware.test' },
}));

vi.mock('@/components/ui/button', () => ({
Button: ({ children }: { children: ReactNode }) => <>{children}</>,
vi.mock('@/components/hero-jobs-for-you-button.lazy', () => ({
HeroJobsForYouButton: () => <a href='/profile/jobs'>Jobs for me</a>,
}));

vi.mock('@/components/primary-cta', () => ({
PrimaryCTA: ({ children }: { children: ReactNode }) => <>{children}</>,
}));
const filter = {
paramKey: 'organizationClassifications',
options: [
{ label: 'AI', value: 'ai' },
{ label: 'Crypto', value: 'crypto' },
{ label: 'Fintech', value: 'fintech' },
{ label: 'Robotics', value: 'robotics' },
{ label: 'Banking', value: 'banking' },
{ label: 'Web2', value: 'tech' },
{ label: 'Out of scope', value: 'out_of_scope' },
{ label: 'Unclassified', value: 'unclassified' },
],
};

vi.mock('@/components/hero-jobs-for-you-button.lazy', () => ({
HeroJobsForYouButton: () => <button>Jobs for You</button>,
}));
afterEach(cleanup);

function renderHomepage(
searchParams = '?page=4&tags=react&classifications=engineering&workModes=fully-remote',
) {
const onUrlUpdate = vi.fn<(event: UrlUpdateEvent) => void>();
render(
<NuqsTestingAdapter
searchParams={searchParams}
onUrlUpdate={onUrlUpdate}
hasMemory
defaultOptions={{ shallow: false }}
>
<HeroCtas organizationClassificationFilter={filter} />
<ActiveFiltersSearch label='Organization classification' {...filter} />
</NuqsTestingAdapter>,
);
return onUrlUpdate;
}

describe('homepage organization filters', () => {
it.each(filter.options.slice(0, 6))(
'applies $label with its canonical value without discarding role, skill or work mode',
async ({ label, value }) => {
const user = userEvent.setup();
const onUrlUpdate = renderHomepage();
await user.click(screen.getByRole('button', { name: `${label} Jobs` }));
await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled());
const update = onUrlUpdate.mock.calls.at(-1)![0];
expect(Object.fromEntries(update.searchParams)).toEqual({
organizationClassifications: value,
tags: 'react',
classifications: 'engineering',
workModes: 'fully-remote',
});
expect(update.options.shallow).toBe(false);
expect(
screen.getByRole('button', { name: `${label} Jobs` }),
).toHaveAttribute('aria-pressed', 'true');
},
);

it('hydrates multiple selected classifications from the URL and removes only the toggled choice', async () => {
const user = userEvent.setup();
const onUrlUpdate = renderHomepage(
'?organizationClassifications=ai,crypto&page=3&orderBy=salary',
);
expect(screen.getByRole('button', { name: 'AI Jobs' })).toHaveAttribute(
'aria-pressed',
'true',
);
expect(screen.getByRole('button', { name: 'Crypto Jobs' })).toHaveAttribute(
'aria-pressed',
'true',
);
await user.click(screen.getByRole('button', { name: 'Fintech Jobs' }));
await waitFor(() =>
expect(
onUrlUpdate.mock.calls.at(-1)?.[0].searchParams.get(filter.paramKey),
).toBe('ai,crypto,fintech'),
);
await user.click(screen.getByRole('button', { name: 'AI Jobs' }));
await waitFor(() =>
expect(
onUrlUpdate.mock.calls.at(-1)?.[0].searchParams.get(filter.paramKey),
).toBe('crypto,fintech'),
);
expect(
Object.fromEntries(onUrlUpdate.mock.calls.at(-1)![0].searchParams),
).toEqual({
organizationClassifications: 'crypto,fintech',
orderBy: 'salary',
});
});

it('clears the final selection instead of leaving an empty filter parameter', async () => {
const user = userEvent.setup();
const onUrlUpdate = renderHomepage(
'?organizationClassifications=tech&page=2&tags=typescript',
);
await user.click(screen.getByRole('button', { name: 'Web2 Jobs' }));
await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled());
expect(
Object.fromEntries(onUrlUpdate.mock.calls.at(-1)![0].searchParams),
).toEqual({ tags: 'typescript' });
});

it('keeps hero selection synchronized when the active filter chip is removed', async () => {
const user = userEvent.setup();
const onUrlUpdate = renderHomepage(
'?organizationClassifications=ai,crypto&page=2&tags=react',
);
await user.click(
screen.getByRole('button', {
name: 'Remove Organization classification filter',
}),
);
await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled());
expect(
Object.fromEntries(onUrlUpdate.mock.calls.at(-1)![0].searchParams),
).toEqual({ tags: 'react' });
expect(screen.getByRole('button', { name: 'AI Jobs' })).toHaveAttribute(
'aria-pressed',
'false',
);
expect(screen.getByRole('button', { name: 'Crypto Jobs' })).toHaveAttribute(
'aria-pressed',
'false',
);
});

describe('HeroCtas', () => {
it('links the homepage directly to the fully remote jobs pillar', () => {
render(<HeroCtas />);
it.each(filter.options.slice(6))(
'supports the $label active filter without promoting it as a homepage CTA',
async ({ label, value }) => {
const user = userEvent.setup();
const onUrlUpdate = renderHomepage(
`?organizationClassifications=${value}&page=2&tags=react`,
);
expect(
screen.queryByRole('button', { name: `${label} Jobs` }),
).not.toBeInTheDocument();
expect(
screen.getByRole('button', { name: 'Organization classification (1)' }),
).toBeInTheDocument();
await user.click(
screen.getByRole('button', {
name: 'Remove Organization classification filter',
}),
);
await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled());
expect(
Object.fromEntries(onUrlUpdate.mock.calls.at(-1)![0].searchParams),
).toEqual({ tags: 'react' });
},
);

it('retains existing pillar CTA destinations without rendering homepage filters', () => {
render(
<HeroCtas
slug='urgently-hiring'
organizationClassificationFilter={filter}
/>,
);
expect(
screen.getByRole('link', { name: 'Fully Remote Jobs' }),
).toHaveAttribute('href', '/lt-fully-remote');
screen.getByRole('link', { name: 'Crypto Beginner Jobs' }),
).toHaveAttribute('href', '/crypto-beginner-jobs');
expect(screen.getByRole('link', { name: 'Jobs for me' })).toHaveAttribute(
'href',
'/profile/jobs',
);
expect(
screen.getByRole('button', { name: 'Jobs for You' }),
).toBeInTheDocument();
screen.queryByRole('button', { name: 'AI Jobs' }),
).not.toBeInTheDocument();
});
});
53 changes: 52 additions & 1 deletion src/components/hero-ctas.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import { POST_JOB_URL } from '@/lib/constants';
import { Button } from '@/components/ui/button';
import { PrimaryCTA } from '@/components/primary-cta';
import { HeroJobsForYouButton } from '@/components/hero-jobs-for-you-button.lazy';
import { useCsvParam } from '@/features/filters/components/active-filters/use-csv-param';
import type { SelectConfigSharedPropertiesSchema } from '@/features/filters/schemas';

interface PillarLink {
slug: string;
Expand Down Expand Up @@ -73,11 +75,60 @@ const PostAJobButton = () => (
</Button>
);

const HomepageCtas = ({
organizationClassificationFilter,
}: Pick<Props, 'organizationClassificationFilter'>) => {
const { checkIsActive, toggleItem } = useCsvParam(
organizationClassificationFilter?.paramKey ?? 'organizationClassifications',
);
const primaryOptions = organizationClassificationFilter?.options.filter(
({ value }) => value !== 'out_of_scope' && value !== 'unclassified',
);

return (
<div className='flex flex-col items-center gap-4'>
{!!primaryOptions?.length && (
<div
role='group'
aria-label='Filter jobs by organization classification'
className='flex flex-wrap items-center justify-center gap-3'
>
{primaryOptions.map(({ label, value }) => {
const active = checkIsActive(value);
return (
<Button
key={value}
size='lg'
variant={active ? 'default' : 'secondary'}
aria-pressed={active}
className='text-base'
onClick={() => toggleItem(value, !active)}
>
{label} Jobs
</Button>
);
})}
</div>
)}
<HeroJobsForYouButton variant='link' />
</div>
);
};

interface Props {
slug?: string;
organizationClassificationFilter?: SelectConfigSharedPropertiesSchema;
}

export const HeroCtas = ({ slug }: Props) => {
export const HeroCtas = ({ slug, organizationClassificationFilter }: Props) => {
if (!slug) {
return (
<HomepageCtas
organizationClassificationFilter={organizationClassificationFilter}
/>
);
}

if (slug === URGENTLY_HIRING.slug) {
return (
<div className='flex flex-col items-center gap-3 sm:flex-row'>
Expand Down
15 changes: 14 additions & 1 deletion src/components/hero-jobs-for-you-button.lazy.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import dynamic from 'next/dynamic';
import { HeroJobsForYouButtonSkeleton } from './hero-jobs-for-you-button.skeleton';

interface Props {
variant?: 'primary' | 'secondary';
variant?: 'primary' | 'secondary' | 'link';
}

// next/dynamic's `loading` can't receive props, so the chunk-load fallback can't
Expand Down Expand Up @@ -33,9 +33,22 @@ const SecondaryButton = dynamic(
},
);

const RecommendationLink = dynamic(
() =>
import('./hero-jobs-for-you-button').then(
(mod) => mod.HeroJobsForYouButton,
),
{
ssr: false,
loading: () => <HeroJobsForYouButtonSkeleton variant='link' />,
},
);

export const HeroJobsForYouButton = ({ variant = 'secondary' }: Props) =>
variant === 'primary' ? (
<PrimaryButton variant='primary' />
) : variant === 'link' ? (
<RecommendationLink variant='link' />
) : (
<SecondaryButton variant='secondary' />
);
13 changes: 12 additions & 1 deletion src/components/hero-jobs-for-you-button.skeleton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { Button } from '@/components/ui/button';
import { PrimaryCTA } from '@/components/primary-cta';

interface Props {
variant?: 'primary' | 'secondary';
variant?: 'primary' | 'secondary' | 'link';
}

// Renders the resolved button's exact box (same component, size, and label) as a
Expand All @@ -11,6 +11,17 @@ interface Props {
export const HeroJobsForYouButtonSkeleton = ({
variant = 'secondary',
}: Props) => {
if (variant === 'link') {
return (
<span
aria-hidden
className='max-w-full px-4 py-2 text-base text-transparent'
>
Sign in to get job recommendations
</span>
);
}

if (variant === 'primary') {
return (
<PrimaryCTA
Expand Down
Loading
Loading