Skip to content
Merged
17 changes: 14 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# NextJS-App-Router-All-In-One
# NextJS15-GraphQL-Boilerplate

`Next15 + App router` boilerplate
`Next15 + GraphQL` boilerplate

## How to use

Expand Down Expand Up @@ -65,6 +65,16 @@ $ yarn g

[You can check detail here](./.github/workflows/pull-request-build-check.yml)

### GraphQL

#### Apollo client with [experimental-nextjs-app-support](https://www.npmjs.com/package/@apollo/experimental-nextjs-app-support)

You can see client apollo provider [here](./src/providers/CustomApolloProvider.tsx)

YOu can see server apollo client [here](./src/graphql/apolloServerClient.ts)

#### Graphql upload with [apollo-upload-client](apollo-upload-client)

### Folder Structure

```bash
Expand All @@ -82,6 +92,7 @@ $ yarn g
│ ├── assets # Static assets like images, fonts, etc.
│ ├── components # Reusable React components
│ ├── constant # Constants used throughout the application
│ ├── graphql # Graphql related files like apollo client
│ ├── hooks # Custom React hooks
│ ├── libs # Library files and utilities
│ ├── provider # Context providers for global state management
Expand Down Expand Up @@ -112,4 +123,4 @@ $ yarn g
- [x] Jest(with server component)
- [x] Storybook(with server component)
- [x] Generator
- [ ] GraphQL
- [x] GraphQL
6 changes: 6 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,11 @@
"e2e:headless": "start-server-and-test dev http://localhost:3000 \"cypress run --e2e\""
},
"dependencies": {
"@apollo/client": "^3.12.4",
"@apollo/experimental-nextjs-app-support": "^0.11.7",
"apollo-upload-client": "^18.0.1",
"cookies-next": "^5.0.2",
"graphql": "^16.10.0",
"next": "15.1.0",
"react": "^19",
"react-dom": "^19",
Expand All @@ -43,6 +48,7 @@
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.1.0",
"@trivago/prettier-plugin-sort-imports": "^5.2.0",
"@types/apollo-upload-client": "^18.0.0",
"@types/jest": "^29.5.14",
"@types/node": "^22",
"@types/react": "^19",
Expand Down
2 changes: 2 additions & 0 deletions src/app/cars/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import 'server-only';

import Cars from '~/components/Cars';

export const dynamic = 'force-dynamic';

export default async function CarsPage(...props: any) {
return <Cars latency={4000} />;
}
7 changes: 7 additions & 0 deletions src/app/landpads/[id]/loading.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
'use client';

const LandpadLoading = () => {
return <div>Loading</div>;
};

export default LandpadLoading;
28 changes: 28 additions & 0 deletions src/app/landpads/[id]/page-ui.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
'use client';

import Link from 'next/link';

import { Landpad } from '../page-ui';
import { LandpadIdPageStyled } from './styled';

interface LandpadIdUIPageProps {
data: Landpad;
}

const LandpadsIdUIPage = ({ data }: LandpadIdUIPageProps) => {
return (
<LandpadIdPageStyled>
<h1>{data.full_name}</h1>
<p>{data.details}</p>
<p className="status">{data.status}</p>
<Link href={data.wikipedia} target="_blank">
{data.wikipedia}
</Link>
<Link href={'/landpads'}>
<p>Go to landpads page</p>
</Link>
</LandpadIdPageStyled>
);
};

export default LandpadsIdUIPage;
40 changes: 40 additions & 0 deletions src/app/landpads/[id]/page.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { render } from '@testing-library/react';

import { apolloServerClient } from '~/graphql/apolloServerClient';
import { GET_ONE_LANDPAD } from '~/graphql/query/landpad';

import LandpadsIdpage from './page';

jest.mock('../../../graphql/apolloServerClient', () => ({
apolloServerClient: jest.fn(),
}));

describe('LandpadDetailPage', () => {
it('should call useQuery with correct variables', async () => {
const response = {
attempted_landings: null,
details: 'details',
full_name: 'full_name',
id: 'id',
landing_type: null,
location: { latitude: 0, longitude: 0, name: 'name', region: 'region' },
status: 'status',
successful_landings: null,
wikipedia: 'wikipedia',
};

const mockClient = {
query: jest.fn().mockResolvedValue({ data: { landpad: response } }),
};
(apolloServerClient as jest.Mock).mockResolvedValue(mockClient);

const params = { id: '1' };

render(await LandpadsIdpage({ params: Promise.resolve(params) }));

expect(mockClient.query).toHaveBeenCalledWith({
query: GET_ONE_LANDPAD,
variables: params,
});
});
});
21 changes: 21 additions & 0 deletions src/app/landpads/[id]/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import 'server-only';

import { apolloServerClient } from '~/graphql/apolloServerClient';
import { GET_ONE_LANDPAD } from '~/graphql/query/landpad';

import { Landpad } from '../page-ui';
import LandpadsIdUIPage from './page-ui';

const LandpadsIdpage = async ({ params }: { params: Promise<{ id: string }> }) => {
const { id } = await params;

const client = await apolloServerClient();
const { data } = await client.query<{ landpad: Landpad }, { id: string }>({
query: GET_ONE_LANDPAD,
variables: { id },
});

return <LandpadsIdUIPage data={data.landpad} />;
};

export default LandpadsIdpage;
3 changes: 3 additions & 0 deletions src/app/landpads/[id]/styled.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import styled from 'styled-components';

export const LandpadIdPageStyled = styled.div``;
34 changes: 34 additions & 0 deletions src/app/landpads/page-ui.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { render, screen } from '@testing-library/react';

import LandpadsUIPage from './page-ui';

describe('LandpadsUIPage', () => {
it('should display loading indicator when loading is true', () => {
render(<LandpadsUIPage data={undefined} loading={true} />);

expect(screen.getByText('loading')).toBeInTheDocument();
});

it('should render landpad data when loading is false', () => {
const data = [
{
attempted_landings: null,
details: 'Landing site details',
full_name: 'Landing Pad 1',
id: '1',
landing_type: null,
location: { latitude: 0, longitude: 0, name: 'Location 1', region: 'Region 1' },
status: 'Active',
successful_landings: null,
wikipedia: 'https://wikipedia.org',
},
];

render(<LandpadsUIPage data={data} loading={false} />);

expect(screen.getByText('Landing Pad 1')).toBeInTheDocument();
expect(screen.getByText('Landing site details')).toBeInTheDocument();
expect(screen.getByText('Active')).toBeInTheDocument();
expect(screen.getByText('https://wikipedia.org')).toBeInTheDocument();
});
});
48 changes: 48 additions & 0 deletions src/app/landpads/page-ui.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
'use client';

import Link from 'next/link';

import { LandpadsLoadingStyled, LandpadsPageStyled } from './styled';

export interface Landpad {
attempted_landings: string | null;
details: string;
full_name: string;
id: string;
landing_type: string | null;
location: { latitude: number; longitude: number; name: string; region: string } | null;
status: string;
successful_landings: string | null;
wikipedia: string;
}

interface LandpadsUIPageProps {
data: Landpad[] | undefined;
loading: boolean;
}

const LandpadsUIPage = ({ data, loading }: LandpadsUIPageProps) => {
if (loading) return <LandpadsLoadingStyled>loading</LandpadsLoadingStyled>;

return (
<LandpadsPageStyled>
{data?.map(v => (
<Link key={v.id} href={`/landpads/${v.id}`}>
<h1>{v.full_name}</h1>
<p>{v.details}</p>
<p className="status">{v.status}</p>
<p
onClick={e => {
e.preventDefault();
window.open(v.wikipedia);
}}
>
{v.wikipedia}
</p>
</Link>
))}
</LandpadsPageStyled>
);
};

export default LandpadsUIPage;
46 changes: 46 additions & 0 deletions src/app/landpads/page.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { render } from '@testing-library/react';

import { useQuery } from '@apollo/client';

import { GET_LANDPADS } from '~/graphql/query/landpad';

import LandPadsPage from './page';

jest.mock('@apollo/client', () => ({
useQuery: jest.fn(),
gql: jest.fn(),
}));

describe('LandPadsPage', () => {
it('should call useQuery with correct variables', () => {
const response = {
data: {
landpads: [
{
attempted_landings: null,
details: 'details',
full_name: 'full_name',
id: 'id',
landing_type: null,
location: { latitude: 0, longitude: 0, name: 'name', region: 'region' },
status: 'status',
successful_landings: null,
wikipedia: 'wikipedia',
},
],
},
loading: false,
};

(useQuery as jest.Mock).mockReturnValue(response);

render(<LandPadsPage />);

expect(useQuery).toHaveBeenCalledWith(
GET_LANDPADS,
expect.objectContaining({
variables: { options: { paginate: { page: 1, limit: 10 } } },
}),
);
});
});
20 changes: 20 additions & 0 deletions src/app/landpads/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
'use client';

import { useQuery } from '@apollo/client';

import { GET_LANDPADS } from '~/graphql/query/landpad';

import LandpadsUIPage, { Landpad } from './page-ui';

const LandpadsPage = () => {
const { data, loading } = useQuery<
{ landpads: Landpad[] },
{ options: { paginate: { page: number; limit: number } } }
>(GET_LANDPADS, {
variables: { options: { paginate: { page: 1, limit: 10 } } },
});

return <LandpadsUIPage data={data?.landpads} loading={loading} />;
};

export default LandpadsPage;
13 changes: 13 additions & 0 deletions src/app/landpads/styled.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import styled from 'styled-components';

export const LandpadsLoadingStyled = styled.div``;

export const LandpadsPageStyled = styled.div`
.status {
color: red;
}

a {
color: inherit;
}
`;
5 changes: 4 additions & 1 deletion src/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import 'server-only';

import type { Metadata } from 'next';

import { CustomApolloProvider } from '~/providers/CustomApolloProvider';
import CustomThemeProvider from '~/providers/CustomThemeProvider';
import { notoSans } from '~/styles/theme';

Expand All @@ -28,7 +29,9 @@ export default function RootLayout({ children }: { children: React.ReactNode })
return (
<html lang="ko">
<body className={notoSans.className}>
<CustomThemeProvider>{children}</CustomThemeProvider>
<CustomApolloProvider>
<CustomThemeProvider>{children}</CustomThemeProvider>
</CustomApolloProvider>
</body>
</html>
);
Expand Down
12 changes: 8 additions & 4 deletions src/app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import { useEffect, useState } from 'react';

import { deleteCookie, getCookie } from 'cookies-next/client';
import Link from 'next/link';

import Button from '~/components/Button';
Expand All @@ -14,9 +15,9 @@ export default function Home() {
useEffect(() => {
if (typeof window === 'undefined') return;

const sessionLogin = sessionStorage.getItem('isLoggedIn');
const token = getCookie('token');

if (sessionLogin !== 'true') return;
if (!token) return;

setIsLoggedIn(true);
}, []);
Expand All @@ -26,11 +27,14 @@ export default function Home() {
{isLoggedIn ? (
<>
<Link href={'/cars'}>
<Button>Go to cars page</Button>
<Button>Go to cars page(REST-API)</Button>
</Link>
<Link href={'/landpads'}>
<Button>Go to landpads page(GRAPHQL)</Button>
</Link>
<Button
onClick={() => {
sessionStorage.setItem('isLoggedIn', 'false');
deleteCookie('token');
setIsLoggedIn(false);
}}
>
Expand Down
Loading
Loading