Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(app): filter blog posts via query param #4604

Merged
merged 1 commit into from
Dec 11, 2024
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
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
Loading