Skip to content

Commit

Permalink
feat(frontend): add query params to blog page
Browse files Browse the repository at this point in the history
  • Loading branch information
luke-h1 committed Dec 11, 2024
1 parent 7e75e55 commit 58cabfc
Show file tree
Hide file tree
Showing 18 changed files with 394 additions and 96 deletions.
38 changes: 38 additions & 0 deletions e2e/blog.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,4 +60,42 @@ test.describe('blog', () => {
await expect(page.locator('h1').first()).toHaveText('Blog');
}
});

test('searches correctly', async () => {
const input = page.getByRole('textbox');

await input.fill('Vault');

const title = page.locator('[data-testid="post-title"]', {
hasText: 'Getting started with aws-vault',
});

await expect(title).toHaveText('Getting started with aws-vault');

const otherBlogPost = page.locator('[data-testid="post-title"]', {
hasText: 'Code linters and formatters',
});

await expect(otherBlogPost).not.toBeVisible();

await expect(page).toHaveURL(`${baseUrl}/blog?title=Vault`);
});

test('searches correctly via visting URL param', async () => {
await page.goto(`${baseUrl}/blog?title=playwright`);
const input = page.getByRole('textbox');

await expect(input).toHaveValue('playwright');

const playwrightBlogPost = page.locator('[data-testid="post-title"]', {
hasText: 'Getting started with Playwright UI testing',
});

await expect(playwrightBlogPost).toBeVisible();

const otherBlogPost = page.locator('[data-testid="post-title"]', {
hasText: 'Code linters and formatters',
});
await expect(otherBlogPost).not.toBeVisible();
});
});
2 changes: 1 addition & 1 deletion next-env.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,4 @@
/// <reference types="next/image-types/global" />

// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information.
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@
"next-themes": "^0.4.4",
"nodemon": "^3.1.7",
"nprogress": "^0.2.0",
"nuqs": "^2.2.3",
"parse-numeric-range": "^1.3.0",
"pino": "^9.5.0",
"prism-react-renderer": "^2.4.0",
Expand Down
118 changes: 85 additions & 33 deletions pnpm-lock.yaml

Large diffs are not rendered by default.

1 change: 0 additions & 1 deletion src/app/about/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,6 @@ export default async function AboutPage() {
))}
</Box>
</Link>

<Spacer height="xxxxl" />
<Box as="section">
<Heading as="h3" fontSize="xl">
Expand Down
4 changes: 4 additions & 0 deletions src/app/blog/[slug]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,10 @@ export default async function PostPage({ params }: Props) {
placeholder="blur"
blurDataURL={imageService.urlFor(post.image.asset) ?? undefined}
alt={post.image.alt ?? post.title}
style={{
maxWidth: '100%',
height: 'auto',
}}
/>
<Meta
items={[
Expand Down
65 changes: 65 additions & 0 deletions src/app/blog/page.client.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { posts } from '@frontend/test/__mocks__/post';
import render from '@frontend/test/render';
import { screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import {
ReadonlyURLSearchParams,
useRouter,
useSearchParams,
} from 'next/navigation';
import PostsClient from './page.client';

jest.mock('next/navigation');

const mockUseSearchParams = jest.mocked(useSearchParams);
const mockUseRouter = jest.mocked(useRouter);

describe('PostsClient', () => {
test('renders posts', async () => {
mockUseSearchParams.mockReturnValue(new ReadonlyURLSearchParams('/blog'));

render(<PostsClient posts={posts} />);

expect(screen.getByText(posts[0].title)).toBeInTheDocument();
expect(screen.getByText(posts[0].intro)).toBeInTheDocument();

expect(screen.getByText(posts[1].title)).toBeInTheDocument();
expect(screen.getByText(posts[1].intro)).toBeInTheDocument();

expect(screen.getByText(posts[2].title)).toBeInTheDocument();
expect(screen.getByText(posts[2].intro)).toBeInTheDocument();
});

test('typing in input adds to query param and filters posts', async () => {
const push = jest.fn();

// @ts-expect-error - we don't need to mock all the properties but TS isn't happy about that
mockUseRouter.mockReturnValue({ push });

mockUseSearchParams.mockReturnValue(new ReadonlyURLSearchParams('/blog'));

render(<PostsClient posts={posts} />);

expect(screen.getByText(posts[0].title)).toBeInTheDocument();
expect(screen.getByText(posts[0].intro)).toBeInTheDocument();

expect(screen.getByText(posts[1].title)).toBeInTheDocument();
expect(screen.getByText(posts[1].intro)).toBeInTheDocument();

expect(screen.getByText(posts[2].title)).toBeInTheDocument();
expect(screen.getByText(posts[2].intro)).toBeInTheDocument();

await userEvent.type(screen.getByRole('textbox'), 'vault');

// vault post
expect(screen.queryByText(posts[2].title)).toBeInTheDocument();
expect(screen.queryByText(posts[2].intro)).toBeInTheDocument();

// rest of posts
expect(screen.queryByText(posts[0].title)).not.toBeInTheDocument();
expect(screen.queryByText(posts[0].intro)).not.toBeInTheDocument();

expect(screen.queryByText(posts[1].title)).not.toBeInTheDocument();
expect(screen.queryByText(posts[1].intro)).not.toBeInTheDocument();
});
});
112 changes: 112 additions & 0 deletions src/app/blog/page.client.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
'use client';

import Box from '@frontend/components/Box';
import Heading from '@frontend/components/Heading';
import Input from '@frontend/components/Input';
import PostItem from '@frontend/components/PostItem';
import Spacer from '@frontend/components/Spacer';
import { Post } from '@frontend/types/sanity';
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
import { ChangeEvent, useCallback, useState } from 'react';

interface Props {
posts: Post[];
}

export default function PostsClient({ posts }: Props) {
const searchParams = useSearchParams();
const router = useRouter();
const pathname = usePathname();
const [query, setQuery] = useState({
title: searchParams.get('title') || '',
});

const createQueryString = useCallback(
(name: string, value: string) => {
const params = new URLSearchParams(searchParams.toString());
if (value) {
params.set(name, value);
} else {
params.delete(name);
}

return params.toString();
},
[searchParams],
);

const handleInputChange = (e: ChangeEvent<HTMLInputElement>) => {
const { name, value } = e.target;

setQuery(prevState => ({
...prevState,
[name]: value,
}));

const queryString = createQueryString(name, value);

router.push(`${pathname}?${queryString}`);
};

const filteredPosts = posts
.filter(post => {
return post.title.toLowerCase().includes(query.title.toLowerCase());
})
.sort((a, b) => {
if (a.publishedAt < b.publishedAt) {
return 1;
}

if (a.publishedAt > b.publishedAt) {
return -1;
}

return 0;
});

const postsByYear: Record<string, Post[]> = {};

filteredPosts.forEach(post => {
const year = new Date(post.publishedAt).getFullYear();

if (!postsByYear[year]) {
postsByYear[year] = [];
}

postsByYear[year].push(post);
});

const sortedYears = Object.keys(postsByYear).sort(
(a, b) => Number(b) - Number(a),
);

return (
<>
<Box>
<Input
onChange={handleInputChange}
placeholder="Search"
value={query.title}
type="text"
id="title"
name="title"
/>
</Box>
<Spacer height="xxxl" />

<Box as="section">
{sortedYears.map(year => (
<Box key={year} marginBottom="xxxl">
<Heading fontSize="xl" as="h2" color="foregroundNeutral">
{year}
</Heading>
<Spacer height="xl" />
{postsByYear[year].map(post => (
<PostItem post={post} key={post._id} />
))}
</Box>
))}
</Box>
</>
);
}
48 changes: 5 additions & 43 deletions src/app/blog/page.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import Box from '@frontend/components/Box';
import Heading from '@frontend/components/Heading';
import Page from '@frontend/components/Page';
import PostItem from '@frontend/components/PostItem';
import Spacer from '@frontend/components/Spacer';
import Text from '@frontend/components/Text';
import postService from '@frontend/services/postService';
import { Post } from '@frontend/types/sanity';
import { Metadata } from 'next';
import { Suspense } from 'react';
import PostsClient from './page.client';

export const revalidate = 1800;

Expand All @@ -19,34 +19,6 @@ export const metadata: Metadata = {
export default async function BlogPage() {
const posts = await postService.getAllPosts();

const allPosts = posts.sort((a, b) => {
if (a.publishedAt < b.publishedAt) {
return 1;
}

if (a.publishedAt > b.publishedAt) {
return -1;
}

return 0;
});

const postsByYear: Record<string, Post[]> = {};

allPosts.forEach(post => {
const year = new Date(post.publishedAt).getFullYear();

if (!postsByYear[year]) {
postsByYear[year] = [];
}

postsByYear[year].push(post);
});

const sortedYears = Object.keys(postsByYear).sort(
(a, b) => Number(b) - Number(a),
);

return (
<Page>
<Box as="section">
Expand All @@ -59,19 +31,9 @@ export default async function BlogPage() {
</Text>
</Box>
<Spacer height="xxxl" />
<Box as="section">
{sortedYears.map(year => (
<Box key={year} marginBottom="xxxl">
<Heading fontSize="xl" as="h2" color="foregroundNeutral">
{year}
</Heading>
<Spacer height="xl" />
{postsByYear[year].map(post => (
<PostItem post={post} key={post._id} />
))}
</Box>
))}
</Box>
<Suspense>
<PostsClient posts={posts} />
</Suspense>
</Page>
);
}
4 changes: 4 additions & 0 deletions src/app/projects/[slug]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,10 @@ export default async function ProjectPage({ params }: Props) {
placeholder="blur"
blurDataURL={imageService.urlFor(project.image.asset)}
alt={project.image.alt ?? project.title}
style={{
maxWidth: '100%',
height: 'auto',
}}
/>
<Meta
items={[
Expand Down
24 changes: 24 additions & 0 deletions src/components/Input/Input.css.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { variables } from '@frontend/styles/variables.css';
import { globalStyle, style } from '@vanilla-extract/css';

export const root = style({
width: '50%',
padding: variables.spacing.sm,
border: '1px solid',
borderColor: variables.color.border,
backgroundColor: variables.color.surface,
borderRadius: variables.radii.md,
':focus': {
outline: 'transparent',
},
':focus-visible': {
outlineWidth: '2px',
outlineStyle: 'solid',
outlineOffset: '2px',
outlineColor: variables.color.outline,
},
});

globalStyle(`${root}::placeholder`, {
color: variables.color.foregroundNeutral,
});
8 changes: 8 additions & 0 deletions src/components/Input/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { InputHTMLAttributes } from 'react';
import * as styles from './Input.css';

type InputProps = InputHTMLAttributes<HTMLInputElement>;

export default function Input(props: InputProps) {
return <input {...props} className={styles.root} />;
}
3 changes: 2 additions & 1 deletion src/components/NowPlaying/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,11 +37,12 @@ export default function NowPlaying() {
blurDataURL={data.albumImageUrl}
placeholder="blur"
alt="Album cover"
layout="intrinsic"
width={65}
height={65}
style={{
borderRadius: '7px',
maxWidth: '100%',
height: 'auto',
}}
/>
</p>
Expand Down
1 change: 0 additions & 1 deletion src/components/ProjectItem/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,6 @@ export default function ProjectItem({ project }: Props) {
</>
</Box>
</Link>

<Box display="flex" alignItems="stretch" className={styles.links}>
<Link
testId="project-github"
Expand Down
Loading

0 comments on commit 58cabfc

Please sign in to comment.