diff --git a/README.md b/README.md index 2439f3a..caeac4b 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ -# NextJS-App-Router-All-In-One +# NextJS15-GraphQL-Boilerplate -`Next15 + App router` boilerplate +`Next15 + GraphQL` boilerplate ## How to use @@ -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 @@ -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 @@ -112,4 +123,4 @@ $ yarn g - [x] Jest(with server component) - [x] Storybook(with server component) - [x] Generator -- [ ] GraphQL +- [x] GraphQL diff --git a/package.json b/package.json index d37287b..fe5e60c 100644 --- a/package.json +++ b/package.json @@ -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", @@ -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", diff --git a/src/app/cars/page.tsx b/src/app/cars/page.tsx index d22e79d..b4a1939 100644 --- a/src/app/cars/page.tsx +++ b/src/app/cars/page.tsx @@ -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 ; } diff --git a/src/app/landpads/[id]/loading.tsx b/src/app/landpads/[id]/loading.tsx new file mode 100644 index 0000000..ae4b294 --- /dev/null +++ b/src/app/landpads/[id]/loading.tsx @@ -0,0 +1,7 @@ +'use client'; + +const LandpadLoading = () => { + return
Loading
; +}; + +export default LandpadLoading; diff --git a/src/app/landpads/[id]/page-ui.tsx b/src/app/landpads/[id]/page-ui.tsx new file mode 100644 index 0000000..f71c646 --- /dev/null +++ b/src/app/landpads/[id]/page-ui.tsx @@ -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 ( + +

{data.full_name}

+

{data.details}

+

{data.status}

+ + {data.wikipedia} + + +

Go to landpads page

+ +
+ ); +}; + +export default LandpadsIdUIPage; diff --git a/src/app/landpads/[id]/page.test.tsx b/src/app/landpads/[id]/page.test.tsx new file mode 100644 index 0000000..452c569 --- /dev/null +++ b/src/app/landpads/[id]/page.test.tsx @@ -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, + }); + }); +}); diff --git a/src/app/landpads/[id]/page.tsx b/src/app/landpads/[id]/page.tsx new file mode 100644 index 0000000..378a594 --- /dev/null +++ b/src/app/landpads/[id]/page.tsx @@ -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 ; +}; + +export default LandpadsIdpage; diff --git a/src/app/landpads/[id]/styled.ts b/src/app/landpads/[id]/styled.ts new file mode 100644 index 0000000..d3e8884 --- /dev/null +++ b/src/app/landpads/[id]/styled.ts @@ -0,0 +1,3 @@ +import styled from 'styled-components'; + +export const LandpadIdPageStyled = styled.div``; diff --git a/src/app/landpads/page-ui.test.tsx b/src/app/landpads/page-ui.test.tsx new file mode 100644 index 0000000..59dd51a --- /dev/null +++ b/src/app/landpads/page-ui.test.tsx @@ -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(); + + 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(); + + 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(); + }); +}); diff --git a/src/app/landpads/page-ui.tsx b/src/app/landpads/page-ui.tsx new file mode 100644 index 0000000..25795a9 --- /dev/null +++ b/src/app/landpads/page-ui.tsx @@ -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 loading; + + return ( + + {data?.map(v => ( + +

{v.full_name}

+

{v.details}

+

{v.status}

+

{ + e.preventDefault(); + window.open(v.wikipedia); + }} + > + {v.wikipedia} +

+ + ))} +
+ ); +}; + +export default LandpadsUIPage; diff --git a/src/app/landpads/page.test.tsx b/src/app/landpads/page.test.tsx new file mode 100644 index 0000000..dbcf443 --- /dev/null +++ b/src/app/landpads/page.test.tsx @@ -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(); + + expect(useQuery).toHaveBeenCalledWith( + GET_LANDPADS, + expect.objectContaining({ + variables: { options: { paginate: { page: 1, limit: 10 } } }, + }), + ); + }); +}); diff --git a/src/app/landpads/page.tsx b/src/app/landpads/page.tsx new file mode 100644 index 0000000..92779df --- /dev/null +++ b/src/app/landpads/page.tsx @@ -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 ; +}; + +export default LandpadsPage; diff --git a/src/app/landpads/styled.ts b/src/app/landpads/styled.ts new file mode 100644 index 0000000..0d06f71 --- /dev/null +++ b/src/app/landpads/styled.ts @@ -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; + } +`; diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 05e0402..957ee98 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -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'; @@ -28,7 +29,9 @@ export default function RootLayout({ children }: { children: React.ReactNode }) return ( - {children} + + {children} + ); diff --git a/src/app/page.tsx b/src/app/page.tsx index 7517afa..372fb15 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -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'; @@ -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); }, []); @@ -26,11 +27,14 @@ export default function Home() { {isLoggedIn ? ( <> - + + + +