Skip to content

Commit 790c450

Browse files
committed
feat: introduce browser history and some fixes
1 parent 348d5ab commit 790c450

File tree

13 files changed

+239
-124
lines changed

13 files changed

+239
-124
lines changed

README.md

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,12 @@ NEXT_PUBLIC_CLIENT_SIDE_MOVIE_REVALIDATE_TIME=3600
3333
NEXT_PUBLIC_SITE_URL=http://localhost:3000
3434
```
3535

36+
The project uses npm. Run the follow to get set up.
37+
38+
```
39+
npm i
40+
```
41+
3642
### Production build of the app
3743

3844
To see the app running optimally you can do a production build of the app and see that working locally.
@@ -44,6 +50,8 @@ npm run start
4450

4551
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
4652

53+
I would advise testing with the production build and using an incognito browser window.
54+
4755
### Local development run
4856

4957
To benefit from hot reloading while working on the app you can run..
@@ -178,11 +186,12 @@ I had limited time so here are a few other things I would look at..
178186
- Storybook for individual components - this would actually make working on the components a breeze.
179187
- Better display for small screens - I would love to put more time into this, I think the search page could be optimised quite nicely for smaller screens
180188
- You could even add additional image sizes to the api endpoint and use different image sizes for different media queries
181-
- Optimise our endpoints but reducing the payload size - that is currently data we send to the frontend that isn't used
182-
- It's not the prettiest web app (I'm not really a designer) again with a bit more time it would be good to put a bit more polish on it terms of looks
189+
- Optimise our endpoints but reducing the payload size - there is currently data we send to the frontend that isn't used
190+
- It's not the prettiest web app (I'm not really a designer) again with a bit more time it would be good to put more polish on it terms of looks
183191
- Add husky to get checks running on push
184192
- Start to move the components into their own folders and using barrel files. The folders would contain tests, storybook files and other sub-components
185193
- There is still some opportunity for code reuse in the endpoints and some of the components (such as MovieListItem and MovieDetailsView)
194+
- Set up a pipeline using github actions and establish a live environment
186195

187196
### What I would have done differently
188197

src/app/api/movies/[movieId]/route.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { fetchTMDB } from '@/lib/tmdb';
44
import type { TMDBMovieDetail } from '@/types/tmdb';
55
import type { APIResponse, MovieDetailResponse } from '@/types/api';
66
import { ApiError } from '@/lib/apiError';
7-
import { enrichWithPosterUrl, getTMDBImageConfig } from '@/lib/tmdb-utls';
7+
import { enrichWithPosterUrl, getTMDBImageConfig } from '@/lib/tmdbUtils';
88

99
const paramsSchema = z.object({
1010
movieId: z.string().regex(/^\d+$/, 'movieId must be a number'),

src/app/api/movies/route.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { z } from 'zod';
33
import { fetchTMDB } from '@/lib/tmdb';
44
import type { TMDBSearchResponse, TMDBMovie } from '@/types/tmdb';
55
import type { APIResponse, MovieSearchResponse } from '@/types/api';
6-
import { enrichWithPosterUrl, getTMDBImageConfig } from '@/lib/tmdb-utls';
6+
import { enrichWithPosterUrl, getTMDBImageConfig } from '@/lib/tmdbUtils';
77
import { ApiError } from '@/lib/apiError';
88

99
const querySchema = z.object({

src/app/page.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ export default async function HomePage({ searchParams }: HomePageProps) {
2323

2424
return (
2525
<HydrationBoundary state={dehydrate(queryClient)}>
26-
<HomeSearchSection />
26+
<HomeSearchSection initialSearch={search} initialPage={parsedPage} />
2727
</HydrationBoundary>
2828
);
2929
}

src/components/LoadingIndicator.tsx

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,7 @@ import { AiOutlineLoading3Quarters } from 'react-icons/ai';
22

33
export function LoadingIndicator() {
44
return (
5-
<span
6-
role="status"
7-
aria-live="polite"
8-
className="absolute right-3 animate-spin text-emerald-950"
9-
>
5+
<span role="status" aria-live="polite" className="absolute right-3 animate-spin text-cyan-950">
106
<AiOutlineLoading3Quarters aria-hidden="true" className="w-4 h-4" />
117
<span className="sr-only">Loading...</span>
128
</span>

src/components/MovieDetailsView.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ export function MovieDetailsView({ movie }: Props) {
1818
alt={formatPostImageAlt(movie.title)}
1919
width={342}
2020
height={513}
21-
className="rounded-md"
21+
className="rounded-md border border-cyan-100 object-contain"
2222
priority
2323
/>
2424
</div>

src/components/MovieListItem.tsx

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,22 +17,27 @@ export function MovieListItem({ movie, search, page }: Props) {
1717
const slug = createMovieSlug(movie.id, movie.original_title);
1818

1919
return (
20-
<div className="flex gap-4 bg-white rounded-xl p-3 h-[255px] border border-cyan-200">
20+
<div className="flex gap-4 bg-white rounded-xl p-3 min-h-[255px] border border-cyan-200">
2121
<div
22-
className={`w-[154px] flex justify-center ${movie.poster_url.default ? 'items-start' : 'bg-stone-100 items-center h-[231px] rounded-md'}`}
22+
className={`w-[154px] h-[231px] flex justify-center rounded-md ${
23+
movie.poster_url.default
24+
? 'items-start'
25+
: 'border border-cyan-100 bg-stone-100 items-center'
26+
}`}
2327
>
2428
{movie.poster_url.default ? (
2529
<Image
2630
src={movie.poster_url.default}
2731
alt={formatPostImageAlt(movie.title)}
2832
width={154}
2933
height={231}
30-
className="rounded-md w-[154px] h-auto max-h-[231px] object-contain"
34+
className="border border-cyan-500 rounded-md object-contain"
3135
/>
3236
) : (
3337
<AiOutlineFileImage className="text-stone-400 text-4xl" />
3438
)}
3539
</div>
40+
3641
<div className="flex-1">
3742
<h3>
3843
<Link

src/components/MovieListSkeleton.tsx

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,6 @@
1-
export function MovieListItemSkeleton() {
1+
function MovieListItemSkeleton() {
22
return (
3-
<div className="flex gap-4 bg-white rounded-xl p-3 h-[255px] border border-cyan-100 animate-pulse">
4-
<div className="w-[154px] h-[231px] bg-stone-100 rounded-md" />
5-
</div>
3+
<div className="flex gap-4 bg-white rounded-xl p-3 h-[255px] border border-cyan-100 animate-pulse"></div>
64
);
75
}
86

@@ -12,7 +10,7 @@ interface Props {
1210

1311
export function MovieListSkeleton({ itemNumber }: Props) {
1412
return (
15-
<div className="space-y-4 mt-8">
13+
<div className="space-y-4">
1614
{Array.from({ length: itemNumber }).map((_, index) => (
1715
<MovieListItemSkeleton key={index} />
1816
))}

src/components/Pagination.tsx

Lines changed: 34 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,13 @@
11
'use client';
22

33
import { AiFillCaretLeft, AiFillCaretRight } from 'react-icons/ai';
4-
import { MouseEventHandler, useEffect, useState } from 'react';
4+
import { MouseEventHandler } from 'react';
55

66
interface PaginationProps extends React.HTMLAttributes<HTMLElement> {
77
currentPage: number;
88
totalPages: number;
9-
search: string;
109
onPageChange: (page: number) => void;
10+
readonly?: boolean;
1111
}
1212

1313
const getPageRange = (currentPage: number, totalPages: number): number[] => {
@@ -16,34 +16,44 @@ const getPageRange = (currentPage: number, totalPages: number): number[] => {
1616
);
1717
};
1818

19-
export function Pagination({ currentPage, totalPages, onPageChange, ...rest }: PaginationProps) {
20-
const [page, setPage] = useState(currentPage);
19+
export function Pagination({
20+
currentPage,
21+
totalPages,
22+
onPageChange,
23+
readonly = false,
24+
...rest
25+
}: PaginationProps) {
26+
const buttonBase = 'px-2 py-1 rounded transition text-purple-800 hover:underline focus:underline';
27+
const buttonCursor = readonly ? 'cursor-not-allowed' : 'cursor-pointer';
2128

22-
useEffect(() => {
23-
setPage(currentPage);
24-
}, [currentPage]);
29+
const readonlyProps = {
30+
disabled: readonly || undefined,
31+
tabIndex: readonly ? -1 : undefined,
32+
};
2533

2634
const createPageHandler: (page: number) => MouseEventHandler<HTMLButtonElement> =
2735
(page) => (e) => {
2836
e.preventDefault();
29-
onPageChange(page);
30-
setPage(page);
37+
if (!readonly) onPageChange(page);
3138
};
3239

33-
const pageRange = getPageRange(page, totalPages);
40+
const pageRange = getPageRange(currentPage, totalPages);
3441

3542
return (
3643
<nav
3744
aria-label="Pagination"
38-
className="flex gap-2 mt-4 flex-wrap justify-between items-center"
45+
className={`flex gap-2 flex-wrap justify-between items-center transition-opacity ${
46+
readonly ? 'opacity-60 select-none' : ''
47+
}`}
3948
{...rest}
4049
>
4150
<div className="w-4 flex justify-start">
42-
{page > 1 && (
51+
{currentPage > 1 && (
4352
<button
44-
onClick={createPageHandler(page - 1)}
45-
className="text-purple-800 hover:underline focus:underline cursor-pointer"
53+
onClick={createPageHandler(currentPage - 1)}
54+
className={`${buttonBase} ${buttonCursor}`}
4655
aria-label="Previous page"
56+
{...readonlyProps}
4757
>
4858
<AiFillCaretLeft aria-hidden="true" />
4959
</button>
@@ -52,13 +62,13 @@ export function Pagination({ currentPage, totalPages, onPageChange, ...rest }: P
5262

5363
<div className="flex gap-1 items-center">
5464
{pageRange.map((p) =>
55-
p === page ? (
65+
p === currentPage ? (
5666
<button
5767
key={`${currentPage}-${p}`}
5868
aria-current="page"
5969
disabled
6070
tabIndex={-1}
61-
className="px-2 py-1 rounded bg-purple-950 text-white pointer-events-none"
71+
className="px-2 py-1 rounded bg-purple-950 text-white pointer-events-none cursor-not-allowed"
6272
>
6373
{p}
6474
</button>
@@ -67,7 +77,8 @@ export function Pagination({ currentPage, totalPages, onPageChange, ...rest }: P
6777
key={`${currentPage}-${p}`}
6878
onClick={createPageHandler(p)}
6979
aria-label={`Page ${p}`}
70-
className="px-2 py-1 rounded transition text-purple-800 hover:underline focus:underline cursor-pointer"
80+
className={`${buttonBase} ${buttonCursor}`}
81+
{...readonlyProps}
7182
>
7283
{p}
7384
</button>
@@ -79,8 +90,9 @@ export function Pagination({ currentPage, totalPages, onPageChange, ...rest }: P
7990
<span aria-hidden="true"></span>
8091
<button
8192
onClick={createPageHandler(totalPages)}
82-
className="text-purple-800 hover:underline focus:underline cursor-pointer"
93+
className={`${buttonBase} ${buttonCursor}`}
8394
aria-label={`Page ${totalPages}`}
95+
{...readonlyProps}
8496
>
8597
{totalPages}
8698
</button>
@@ -89,11 +101,12 @@ export function Pagination({ currentPage, totalPages, onPageChange, ...rest }: P
89101
</div>
90102

91103
<div className="w-4 flex justify-end">
92-
{page < totalPages && (
104+
{currentPage < totalPages && (
93105
<button
94-
onClick={createPageHandler(page + 1)}
95-
className="text-purple-800 hover:underline focus:underline cursor-pointer"
106+
onClick={createPageHandler(currentPage + 1)}
107+
className={`${buttonBase} ${buttonCursor}`}
96108
aria-label="Next page"
109+
{...readonlyProps}
97110
>
98111
<AiFillCaretRight aria-hidden="true" />
99112
</button>

src/features/home/HomeSearchSection.test.tsx

Lines changed: 14 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -6,23 +6,11 @@ import { http, HttpResponse } from 'msw';
66
import { server } from '@/test/server';
77
import { createTestQueryClient } from '@/test/utils';
88

9-
vi.mock('next/navigation', () => {
10-
const searchParams = new URLSearchParams();
11-
12-
return {
13-
useSearchParams: () => searchParams,
14-
useRouter: () => ({
15-
replace: vi.fn(),
16-
}),
17-
usePathname: () => '/',
18-
};
19-
});
20-
21-
function renderWithClient() {
9+
function renderWithClient(initialSearch = '', initialPage = 1) {
2210
const queryClient = createTestQueryClient();
2311
render(
2412
<QueryClientProvider client={queryClient}>
25-
<HomeSearchSection />
13+
<HomeSearchSection initialSearch={initialSearch} initialPage={initialPage} />
2614
</QueryClientProvider>
2715
);
2816
}
@@ -52,7 +40,9 @@ function setupPaginatedMock(totalPages: number) {
5240
}
5341

5442
async function triggerSearch(query: string) {
55-
await userEvent.type(screen.getByPlaceholderText(/Search movies.../i), query);
43+
const input = screen.getByPlaceholderText(/Search movies.../i);
44+
await userEvent.clear(input);
45+
await userEvent.type(input, query);
5646
await waitFor(() => {
5747
expect(screen.getByText(`Movie Page 1`)).toBeInTheDocument();
5848
});
@@ -93,7 +83,9 @@ describe('HomeSearchSection', () => {
9383

9484
renderWithClient();
9585

96-
await userEvent.type(screen.getByPlaceholderText(/Search movies.../i), 'matrix');
86+
const input = screen.getByPlaceholderText(/Search movies.../i);
87+
await userEvent.clear(input);
88+
await userEvent.type(input, 'matrix');
9789

9890
expect(await screen.findByRole('status')).toBeInTheDocument();
9991

@@ -107,7 +99,9 @@ describe('HomeSearchSection', () => {
10799

108100
renderWithClient();
109101

110-
await userEvent.type(screen.getByPlaceholderText(/Search movies.../i), 'nothing');
102+
const input = screen.getByPlaceholderText(/Search movies.../i);
103+
await userEvent.clear(input);
104+
await userEvent.type(input, 'nothing');
111105

112106
await waitFor(() => {
113107
expect(screen.getByText(/No results match your search/i)).toBeInTheDocument();
@@ -130,7 +124,9 @@ describe('HomeSearchSection', () => {
130124

131125
renderWithClient();
132126

133-
await userEvent.type(screen.getByPlaceholderText(/Search movies.../i), 'fail');
127+
const input = screen.getByPlaceholderText(/Search movies.../i);
128+
await userEvent.clear(input); // Defensive, makes sure field is blank
129+
await userEvent.type(input, 'fail');
134130

135131
await waitFor(() => {
136132
expect(screen.getByRole('alert')).toHaveTextContent(

0 commit comments

Comments
 (0)