chore: add mock API route handlers and dev env config
This commit is contained in:
@@ -0,0 +1 @@
|
||||
export { default as ExperienceSection } from './ui/ExperienceSection/ExperienceSection';
|
||||
@@ -0,0 +1,87 @@
|
||||
vi.mock('$shared/api', () => ({
|
||||
getCollection: vi.fn(),
|
||||
}));
|
||||
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { getCollection } from '$shared/api';
|
||||
import ExperienceSection from './ExperienceSection';
|
||||
|
||||
const mockItems = [
|
||||
{
|
||||
id: '1',
|
||||
collectionId: 'c1',
|
||||
collectionName: 'experience',
|
||||
created: '',
|
||||
updated: '',
|
||||
company: 'Acme Corp',
|
||||
role: 'Senior Developer',
|
||||
start_date: '2022-01-01T00:00:00Z',
|
||||
end_date: null,
|
||||
description: 'Built critical systems.',
|
||||
order: 1,
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
collectionId: 'c1',
|
||||
collectionName: 'experience',
|
||||
created: '',
|
||||
updated: '',
|
||||
company: 'Beta Ltd',
|
||||
role: 'Junior Developer',
|
||||
start_date: '2020-01-01T00:00:00Z',
|
||||
end_date: '2021-12-31T00:00:00Z',
|
||||
description: 'Learned the ropes.',
|
||||
order: 2,
|
||||
},
|
||||
];
|
||||
|
||||
const listResponse = (items: typeof mockItems) => ({
|
||||
items,
|
||||
page: 1,
|
||||
perPage: 50,
|
||||
totalItems: items.length,
|
||||
totalPages: 1,
|
||||
});
|
||||
|
||||
describe('ExperienceSection', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(getCollection).mockResolvedValue(listResponse(mockItems) as never);
|
||||
});
|
||||
|
||||
describe('rendering', () => {
|
||||
it('renders a card for each experience record', async () => {
|
||||
render(await ExperienceSection());
|
||||
expect(screen.getByText('Senior Developer')).toBeInTheDocument();
|
||||
expect(screen.getByText('Junior Developer')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders company names', async () => {
|
||||
render(await ExperienceSection());
|
||||
expect(screen.getByText('Acme Corp')).toBeInTheDocument();
|
||||
expect(screen.getByText('Beta Ltd')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('formats open-ended period as "Present"', async () => {
|
||||
render(await ExperienceSection());
|
||||
expect(screen.getByText('2022 — Present')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('formats closed period with year range', async () => {
|
||||
render(await ExperienceSection());
|
||||
expect(screen.getByText('2020 — 2021')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders description text', async () => {
|
||||
render(await ExperienceSection());
|
||||
expect(screen.getByText('Built critical systems.')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('empty state', () => {
|
||||
it('renders empty container when no items', async () => {
|
||||
vi.mocked(getCollection).mockResolvedValue(listResponse([]) as never);
|
||||
const { container } = render(await ExperienceSection());
|
||||
expect(container.firstChild).toBeEmptyDOMElement();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
import { ExperienceCard } from '$entities/experience';
|
||||
import type { ExperienceRecord } from '$shared/api';
|
||||
import { getCollection } from '$shared/api';
|
||||
import { formatYearRange } from '$shared/lib';
|
||||
|
||||
/**
|
||||
* Experience section component.
|
||||
* Lists work history entries sorted by order field.
|
||||
*/
|
||||
export default async function ExperienceSection() {
|
||||
const { items } = await getCollection<ExperienceRecord>('experience', {
|
||||
sort: 'order',
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{items.map((exp) => (
|
||||
<ExperienceCard
|
||||
key={exp.id}
|
||||
title={exp.role}
|
||||
company={exp.company}
|
||||
period={formatYearRange(exp.start_date, exp.end_date)}
|
||||
description={exp.description}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { default as ProjectsSection } from './ui/ProjectsSection/ProjectsSection';
|
||||
@@ -0,0 +1,88 @@
|
||||
vi.mock('$shared/api', () => ({
|
||||
getCollection: vi.fn(),
|
||||
}));
|
||||
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { getCollection } from '$shared/api';
|
||||
import ProjectsSection from './ProjectsSection';
|
||||
|
||||
const mockItems = [
|
||||
{
|
||||
id: '1',
|
||||
collectionId: 'col1',
|
||||
collectionName: 'projects',
|
||||
created: '',
|
||||
updated: '',
|
||||
title: 'My App',
|
||||
year: '2024',
|
||||
role: 'Lead Developer',
|
||||
description: 'A cool app.',
|
||||
details: ['Feature A'],
|
||||
stack: ['React', 'TypeScript'],
|
||||
image: '',
|
||||
order: 1,
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
collectionId: 'col1',
|
||||
collectionName: 'projects',
|
||||
created: '',
|
||||
updated: '',
|
||||
title: 'Other Project',
|
||||
year: '2023',
|
||||
role: 'Developer',
|
||||
description: 'Another thing.',
|
||||
details: [],
|
||||
stack: ['Go'],
|
||||
image: '',
|
||||
order: 2,
|
||||
},
|
||||
];
|
||||
|
||||
const listResponse = (items: typeof mockItems) => ({
|
||||
items,
|
||||
page: 1,
|
||||
perPage: 50,
|
||||
totalItems: items.length,
|
||||
totalPages: 1,
|
||||
});
|
||||
|
||||
describe('ProjectsSection', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(getCollection).mockResolvedValue(listResponse(mockItems) as never);
|
||||
});
|
||||
|
||||
describe('rendering', () => {
|
||||
it('renders a card for each project', async () => {
|
||||
render(await ProjectsSection());
|
||||
expect(screen.getByText('My App')).toBeInTheDocument();
|
||||
expect(screen.getByText('Other Project')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders project descriptions', async () => {
|
||||
render(await ProjectsSection());
|
||||
expect(screen.getByText('A cool app.')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders tech stack tags', async () => {
|
||||
render(await ProjectsSection());
|
||||
expect(screen.getByText('React')).toBeInTheDocument();
|
||||
expect(screen.getByText('TypeScript')).toBeInTheDocument();
|
||||
expect(screen.getByText('Go')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders year badge for each project', async () => {
|
||||
render(await ProjectsSection());
|
||||
expect(screen.getByText('2024')).toBeInTheDocument();
|
||||
expect(screen.getByText('2023')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('empty state', () => {
|
||||
it('renders empty grid when no items', async () => {
|
||||
vi.mocked(getCollection).mockResolvedValue(listResponse([]) as never);
|
||||
const { container } = render(await ProjectsSection());
|
||||
expect(container.firstChild).toBeEmptyDOMElement();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
import { ProjectCard } from '$entities/project';
|
||||
import type { ProjectRecord } from '$shared/api';
|
||||
import { getCollection } from '$shared/api';
|
||||
|
||||
/** Base URL for PocketBase file storage */
|
||||
const PB_URL = process.env.NEXT_PUBLIC_PB_URL || 'http://127.0.0.1:8090';
|
||||
|
||||
/**
|
||||
* Builds a PocketBase file URL for a project image.
|
||||
*/
|
||||
function buildImageUrl(project: ProjectRecord): string | undefined {
|
||||
if (!project.image) {
|
||||
return undefined;
|
||||
}
|
||||
return `${PB_URL}/api/files/${project.collectionId}/${project.id}/${project.image}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Projects section component.
|
||||
* Displays portfolio projects in a two-column grid, sorted by order field.
|
||||
*/
|
||||
export default async function ProjectsSection() {
|
||||
const { items } = await getCollection<ProjectRecord>('projects', {
|
||||
sort: 'order',
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{items.map((project) => (
|
||||
<ProjectCard
|
||||
key={project.id}
|
||||
title={project.title}
|
||||
year={project.year}
|
||||
description={project.description}
|
||||
tags={project.stack}
|
||||
imageUrl={buildImageUrl(project)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { SectionsAccordion } from './ui/SectionsAccordion/SectionsAccordion';
|
||||
@@ -0,0 +1,106 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import type { SectionRecord } from '$entities/Section';
|
||||
import { SectionsAccordion } from './SectionsAccordion';
|
||||
|
||||
const baseRecord = { collectionId: 'c1', collectionName: 'sections', created: '', updated: '' };
|
||||
|
||||
const sections: SectionRecord[] = [
|
||||
{ ...baseRecord, id: '1', slug: 'intro', title: 'Intro', number: '01', order: 1 },
|
||||
{ ...baseRecord, id: '2', slug: 'bio', title: 'Bio', number: '02', order: 2 },
|
||||
{ ...baseRecord, id: '3', slug: 'skills', title: 'Skills', number: '03', order: 3 },
|
||||
];
|
||||
|
||||
describe('SectionsAccordion', () => {
|
||||
describe('initial state', () => {
|
||||
it('renders the first section as active (h1)', () => {
|
||||
render(
|
||||
<SectionsAccordion sections={sections}>
|
||||
<div>Intro content</div>
|
||||
<div>Bio content</div>
|
||||
<div>Skills content</div>
|
||||
</SectionsAccordion>,
|
||||
);
|
||||
expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent('01. Intro');
|
||||
});
|
||||
|
||||
it('renders remaining sections as collapsed buttons', () => {
|
||||
render(
|
||||
<SectionsAccordion sections={sections}>
|
||||
<div>Intro content</div>
|
||||
<div>Bio content</div>
|
||||
<div>Skills content</div>
|
||||
</SectionsAccordion>,
|
||||
);
|
||||
const buttons = screen.getAllByRole('button');
|
||||
expect(buttons).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('interaction', () => {
|
||||
it('activates clicked section', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<SectionsAccordion sections={sections}>
|
||||
<div>Intro content</div>
|
||||
<div>Bio content</div>
|
||||
<div>Skills content</div>
|
||||
</SectionsAccordion>,
|
||||
);
|
||||
await user.click(screen.getByRole('button', { name: /02\. Bio/i }));
|
||||
expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent('02. Bio');
|
||||
});
|
||||
|
||||
it('deactivates previously active section after click', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<SectionsAccordion sections={sections}>
|
||||
<div>Intro content</div>
|
||||
<div>Bio content</div>
|
||||
<div>Skills content</div>
|
||||
</SectionsAccordion>,
|
||||
);
|
||||
await user.click(screen.getByRole('button', { name: /02\. Bio/i }));
|
||||
expect(screen.queryByRole('heading', { level: 1, name: /01\. Intro/i })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('only one section is active at a time', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<SectionsAccordion sections={sections}>
|
||||
<div>Intro content</div>
|
||||
<div>Bio content</div>
|
||||
<div>Skills content</div>
|
||||
</SectionsAccordion>,
|
||||
);
|
||||
await user.click(screen.getByRole('button', { name: /03\. Skills/i }));
|
||||
expect(screen.getAllByRole('heading', { level: 1 })).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('content slots', () => {
|
||||
it('shows active section content', () => {
|
||||
render(
|
||||
<SectionsAccordion sections={sections}>
|
||||
<div>Intro content</div>
|
||||
<div>Bio content</div>
|
||||
<div>Skills content</div>
|
||||
</SectionsAccordion>,
|
||||
);
|
||||
expect(screen.getByText('Intro content')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows correct content after switching sections', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<SectionsAccordion sections={sections}>
|
||||
<div>Intro content</div>
|
||||
<div>Bio content</div>
|
||||
<div>Skills content</div>
|
||||
</SectionsAccordion>,
|
||||
);
|
||||
await user.click(screen.getByRole('button', { name: /02\. Bio/i }));
|
||||
expect(screen.getByText('Bio content')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
'use client';
|
||||
|
||||
import type { ReactNode } from 'react';
|
||||
import { Children, useState } from 'react';
|
||||
import type { SectionRecord } from '$entities/Section';
|
||||
import { SectionAccordion } from '$entities/Section';
|
||||
|
||||
type Props = {
|
||||
/**
|
||||
* Ordered section metadata — drives navigation labels and IDs
|
||||
*/
|
||||
sections: SectionRecord[];
|
||||
/**
|
||||
* Pre-rendered RSC content slots, one per section, matched by index
|
||||
*/
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
/**
|
||||
* Manages accordion open/close state across all portfolio sections.
|
||||
* Receives RSC content as opaque children slots, matched positionally to sections.
|
||||
*/
|
||||
export function SectionsAccordion({ sections, children }: Props) {
|
||||
const [activeSlug, setActiveSlug] = useState(sections[0]?.slug ?? '');
|
||||
const slots = Children.toArray(children);
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{sections.map((section, i) => (
|
||||
<SectionAccordion
|
||||
key={section.slug}
|
||||
id={section.slug}
|
||||
number={section.number}
|
||||
title={section.title}
|
||||
isActive={activeSlug === section.slug}
|
||||
onClick={() => setActiveSlug(section.slug)}
|
||||
>
|
||||
{slots[i]}
|
||||
</SectionAccordion>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user