Skip to content

Commit

Permalink
Merge pull request #4612 from luke-h1/feat/add-sort-order
Browse files Browse the repository at this point in the history
feat(frontend): add sort order to blog page
  • Loading branch information
luke-h1 authored Dec 15, 2024
2 parents 91bbd0d + 3fae286 commit 3f36703
Show file tree
Hide file tree
Showing 12 changed files with 941 additions and 121 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>
);
}
13 changes: 0 additions & 13 deletions src/components/PostItem/PostItem.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,18 +16,5 @@ describe('PostItem', () => {
'href',
`/blog/${posts[0].slug.current}`,
);

for (let i = 0; i < posts[0].tags.length; i += 1) {
const tagId = `post-tag-${posts[0].tags[i]._id}`;

expect(screen.getByTestId(tagId)).toHaveTextContent(
posts[0].tags[i].title.toUpperCase(),
);

expect(screen.getByTestId(tagId)).toHaveAttribute(
'href',
`/blog/tags/${posts[0].tags[i].slug.current}`,
);
}
});
});
Loading

0 comments on commit 3f36703

Please sign in to comment.