Skip to content

Commit

Permalink
feat(frontend): add sort order to blog page
Browse files Browse the repository at this point in the history
  • Loading branch information
luke-h1 committed Dec 15, 2024
1 parent 91bbd0d commit cc52ac8
Show file tree
Hide file tree
Showing 11 changed files with 941 additions and 108 deletions.
2 changes: 0 additions & 2 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
name: Build
on:
push:
branches: [dev]
pull_request:
branches: [dev, main]
env:
Expand Down
50 changes: 26 additions & 24 deletions e2e/blog.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,41 +61,43 @@ test.describe('blog', () => {
}
});

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

await input.fill('Vault');
await input.fill('Vault');

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

await expect(title).toHaveText('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',
});
const otherBlogPost = page.locator('[data-testid="post-title"]', {
hasText: 'Code linters and formatters',
});

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

await expect(page).toHaveURL(`${baseUrl}/blog?title=Vault`);
});
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');
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');
await expect(input).toHaveValue('playwright');

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

await expect(playwrightBlogPost).toBeVisible();
await expect(playwrightBlogPost).toBeVisible();

const otherBlogPost = page.locator('[data-testid="post-title"]', {
hasText: 'Code linters and formatters',
const otherBlogPost = page.locator('[data-testid="post-title"]', {
hasText: 'Code linters and formatters',
});
await expect(otherBlogPost).not.toBeVisible();
});
await expect(otherBlogPost).not.toBeVisible();
});
});
4 changes: 2 additions & 2 deletions next.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,9 @@ const withVanillaExtract = createVanillaExtractPlugin();

const contentSecurityPolicy = `
default-src 'self';
script-src 'self' 'unsafe-eval' 'unsafe-inline' *.youtube.com *.twitter.com *.googletagmanager.com *.vitals.vercel-insights.com static.cloudflareinsights.com eu-assets.i.posthog.com js-agent.newrelic.com;
script-src 'self' 'unsafe-eval' 'unsafe-inline' *.youtube.com *.twitter.com *.googletagmanager.com *.vitals.vercel-insights.com static.cloudflareinsights.com js-agent.newrelic.com;
child-src *.youtube.com *.google.com *.twitter.com *.googletagmanager.com *.vitals.vercel-insights.com;
style-src 'self' 'unsafe-inline' *.googleapis.com app-static.eu.posthog.com https://maxcdn.bootstrapcdn.com/font-awesome/latest/css/font-awesome.min.css;
style-src 'self' 'unsafe-inline' *.googleapis.com https://maxcdn.bootstrapcdn.com/font-awesome/latest/css/font-awesome.min.css;
img-src * blob: data: https://*.googletagmanager.com;
media-src 'none';
connect-src * cloudflareinsights.com;
Expand Down
95 changes: 94 additions & 1 deletion src/app/blog/page.client.test.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { posts } from '@frontend/test/__mocks__/post';
import render from '@frontend/test/render';
import { screen } from '@testing-library/react';
import { fireEvent, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import {
ReadonlyURLSearchParams,
Expand Down Expand Up @@ -62,4 +62,97 @@ describe('PostsClient', () => {
expect(screen.queryByText(posts[1].title)).not.toBeInTheDocument();
expect(screen.queryByText(posts[1].intro)).not.toBeInTheDocument();
});

test('ASC sort order updates query param and sorts posts', async () => {
const push = jest.fn();
mockUseSearchParams.mockReturnValue(new ReadonlyURLSearchParams('/blog'));

// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
mockUseRouter.mockReturnValue({ push });

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

const select = screen.getByTestId('sort-order');

fireEvent.change(select, { target: { value: 'asc' } });
expect(push).toHaveBeenCalledWith('undefined?%2Fblog=&order=asc');

const headings = screen.getAllByTestId(/^year-heading-/);

const headingText = headings.map(heading => heading.textContent);

expect(headingText).toEqual(['2020', '2021', '2022', '2023', '2024']);

const postHeadings = screen.getAllByTestId(/^post-title/);

const postHeadingsText = postHeadings.map(heading => heading.textContent);

expect(postHeadingsText).toEqual([
'First blog post',
'Forcing git merges',
'Next.js SSR notes',
'Full stack deploy with dokku',
'Extending multiple classes in TypegraphQL',
'How to rename local & remote git branches',
'Preventing fouc with Chakra UI',
'Set default node version with nvm',
'Launching multiple Iterm2 windows with one script',
'Getting started with Playwright UI testing',
'Conventional commits, a better way to commit',
'What Next.js 13 means for end-users',
'How to build a custom Prisma generator',
'How to use the Spotify API with Next.js',
'TypeScript - why to use unknown instead of any',
'2023 in review and 2024 goals',
'Getting started with aws-vault',
'How to connect a custom domain to AWS API gateway',
'Code linters and formatters',
]);
});

test('DESC sort order updates query param and sorts posts', async () => {
const push = jest.fn();
mockUseSearchParams.mockReturnValue(new ReadonlyURLSearchParams('/blog'));

// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
mockUseRouter.mockReturnValue({ push });

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

const select = screen.getByTestId('sort-order');
fireEvent.change(select, { target: { value: 'desc' } });
expect(push).toHaveBeenCalledWith('undefined?%2Fblog=&order=desc');

const headings = screen.getAllByTestId(/^year-heading-/);
const headingText = headings.map(heading => heading.textContent);

expect(headingText).toEqual(['2024', '2023', '2022', '2021', '2020']);

const postHeadings = screen.getAllByTestId(/^post-title/);
const postHeadingsText = postHeadings.map(heading => heading.textContent);

expect(postHeadingsText).toEqual([
'Code linters and formatters',
'How to connect a custom domain to AWS API gateway',
'Getting started with aws-vault',
'2023 in review and 2024 goals',
'How to use the Spotify API with Next.js',
'TypeScript - why to use unknown instead of any',
'How to build a custom Prisma generator',
'What Next.js 13 means for end-users',
'Conventional commits, a better way to commit',
'Getting started with Playwright UI testing',
'Launching multiple Iterm2 windows with one script',
'Set default node version with nvm',
'Preventing fouc with Chakra UI',
'How to rename local & remote git branches',
'Extending multiple classes in TypegraphQL',
'Full stack deploy with dokku',
'Next.js SSR notes',
'Forcing git merges',
'First blog post',
]);
});
});
61 changes: 49 additions & 12 deletions src/app/blog/page.client.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ 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 Select from '@frontend/components/Select';
import Spacer from '@frontend/components/Spacer';
import { Post } from '@frontend/types/sanity';
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
Expand All @@ -13,13 +14,18 @@ interface Props {
posts: Post[];
}

type SortOrder = 'asc' | 'desc';

export default function PostsClient({ posts }: Props) {
const searchParams = useSearchParams();
const router = useRouter();
const pathname = usePathname();
const [query, setQuery] = useState({
title: searchParams.get('title') || '',
});
const [sortOrder, setSortOrder] = useState<SortOrder>(
(searchParams.get('order') as SortOrder) || 'desc',
);

const createQueryString = useCallback(
(name: string, value: string) => {
Expand Down Expand Up @@ -48,20 +54,25 @@ export default function PostsClient({ posts }: Props) {
router.push(`${pathname}?${queryString}`);
};

const handleSelectChange = (e: ChangeEvent<HTMLSelectElement>) => {
setSortOrder(e.target.value as SortOrder);
const queryString = createQueryString('order', e.target.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;
if (sortOrder === 'asc') {
return (
new Date(a.publishedAt).getTime() - new Date(b.publishedAt).getTime()
);
}

return 0;
return (
new Date(b.publishedAt).getTime() - new Date(a.publishedAt).getTime()
);
});

const postsByYear: Record<string, Post[]> = {};
Expand All @@ -76,9 +87,12 @@ export default function PostsClient({ posts }: Props) {
postsByYear[year].push(post);
});

const sortedYears = Object.keys(postsByYear).sort(
(a, b) => Number(b) - Number(a),
);
const sortedYears = Object.keys(postsByYear).sort((a, b) => {
if (sortOrder === 'asc') {
return Number(a) - Number(b);
}
return Number(b) - Number(a);
});

return (
<>
Expand All @@ -90,14 +104,37 @@ export default function PostsClient({ posts }: Props) {
type="text"
id="title"
name="title"
label="Search"
/>
</Box>
<Box>
<Select
data-testid="sort-order"
label="Sort Order"
onChange={handleSelectChange}
options={[
{
label: 'Descending',
value: 'desc',
},
{
label: 'Ascending',
value: 'asc',
},
]}
/>
</Box>
<Spacer height="xxxl" />

<Box as="section">
{sortedYears.map(year => (
<Box key={year} marginBottom="xxxl">
<Heading fontSize="xl" as="h2" color="foregroundNeutral">
<Heading
fontSize="xl"
as="h2"
color="foregroundNeutral"
testId={`year-heading-${year}`}
>
{year}
</Heading>
<Spacer height="xl" />
Expand Down
10 changes: 9 additions & 1 deletion src/components/Input/Input.css.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,20 @@
import { variables } from '@frontend/styles/variables.css';
import { globalStyle, style } from '@vanilla-extract/css';

export const container = style({
display: 'flex',
flexDirection: 'column',
});

export const label = style({
color: variables.color.foregroundNeutral,
});

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',
Expand Down
14 changes: 11 additions & 3 deletions src/components/Input/index.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,16 @@
/* eslint-disable jsx-a11y/label-has-associated-control */
import { InputHTMLAttributes } from 'react';
import * as styles from './Input.css';

type InputProps = InputHTMLAttributes<HTMLInputElement>;
interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
label?: string;
}

export default function Input(props: InputProps) {
return <input {...props} className={styles.root} />;
export default function Input({ label, ...props }: InputProps) {
return (
<div className={styles.container}>
{label && <label className={styles.label}>{label}</label>}
<input {...props} className={styles.root} />
</div>
);
}
36 changes: 36 additions & 0 deletions src/components/Select/Select.css.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { variables } from '@frontend/styles/variables.css';
import { style } from '@vanilla-extract/css';

export const container = style({
display: 'flex',
flexDirection: 'column',
gap: '0.5rem',
marginTop: variables.spacing.md,
});

export const label = style({
fontSize: '1rem',
color: variables.color.foregroundNeutral,
});

export const select = style({
width: '50%',
padding: '0.5rem',
border: `1px solid ${variables.color.border}`,
color: variables.color.foregroundNeutral,

borderRadius: '4px',
fontSize: '1rem',
appearance: 'none',
cursor: 'pointer',
':focus': {
outline: 'none',
borderColor: variables.color.border,
boxShadow: `0 0 0 3px ${variables.color.borderFaint}`,
},
});

export const hint = style({
fontSize: '0.875rem',
color: variables.color.page,
});
Loading

0 comments on commit cc52ac8

Please sign in to comment.