Crée des composants React et pages Next.js avec Relay GraphQL dans admin-next/. Couvre fragments, queries, pages dynamiques, hooks et patterns du projet.
Guide pour créer des composants React et pages Next.js dans admin-next/ avec les patterns du projet.
admin-next/
├── pages/admin-next/ # Route back office (Pages Router)
│ ├── my-feature.tsx # Page simple
│ └── entity/
│ ├── [entityId].tsx # Page dynamique
│ └── [entityId]/
│ └── settings.tsx # Sous-page
├── components/
│ ├── BackOffice/ # Composants admin
│ └── FrontOffice/ # Composants front-end
├── shared/ # Composants réutilisables
├── hooks/ # Custom hooks
├── mutations/ # Mutations GraphQL
└── __generated__/ # Types Relay générés
// components/BackOffice/MyFeature/MyFeatureCard.tsx
import { graphql, useFragment } from 'react-relay'
import type { MyFeatureCard_entity$key } from '@relay/MyFeatureCard_entity.graphql'
import { Card, CardContent, Text } from '@cap-collectif/ui'
const FRAGMENT = graphql`
fragment MyFeatureCard_entity on Entity {
id
title
description
createdAt
}
`
type Props = {
entity: MyFeatureCard_entity$key
}
export const MyFeatureCard: React.FC<Props> = ({ entity: entityRef }) => {
const entity = useFragment(FRAGMENT, entityRef)
return (
<Card>
<CardContent primaryInfo={entity.title}>
<Text>{entity.description}</Text>
</CardContent>
</Card>
)
}
| Element | Pattern | Exemple |
|---|---|---|
| Fragment | ComponentName_fieldName |
ProposalCard_proposal |
| Type data | FragmentName$data |
ProposalCard_proposal$data |
| Type key | FragmentName$key |
ProposalCard_proposal$key |
| Variable | FRAGMENT |
const FRAGMENT = graphql... |
const FRAGMENT = graphql`
fragment MyList_query on Query
@argumentDefinitions(
count: { type: "Int", defaultValue: 10 }
cursor: { type: "String" }
orderBy: { type: "[EntityOrder]" }
)
@refetchable(queryName: "MyListPaginationQuery") {
entities(first: $count, after: $cursor, orderBy: $orderBy)
@connection(key: "MyList_entities") {
edges {
node {
id
...MyFeatureCard_entity
}
}
totalCount
}
}
`
const FRAGMENT = graphql`
fragment ParentComponent_data on Query {
viewer {
...ChildComponent_viewer
}
node(id: $id) {
...AnotherChild_node
}
}
`
// pages/admin-next/my-feature.tsx
import * as React from 'react'
import { Suspense } from 'react'
import type { NextPage } from 'next'
import { graphql, useQueryLoader, usePreloadedQuery } from 'react-relay'
import type { PreloadedQuery } from 'react-relay'
import { useIntl } from 'react-intl'
import { Spinner } from '@cap-collectif/ui'
import Layout from '@components/BackOffice/Layout/Layout'
import { withPageAuthRequired } from '@utils/withPageAuthRequired'
import type { PageProps } from '@utils/types'
import type { myFeatureQuery as QueryType } from '@relay/myFeatureQuery.graphql'
// 1. Query au niveau module
export const myFeatureQuery = graphql`
query myFeatureQuery {
viewer {
id
username
isAdmin
}
# ... other fields
}
`
// 2. Props du composant inner
type ContentProps = {
queryReference: PreloadedQuery<QueryType>
}
// 3. Composant inner avec usePreloadedQuery
const MyFeatureContent: React.FC<ContentProps> = ({ queryReference }) => {
const query = usePreloadedQuery<QueryType>(myFeatureQuery, queryReference)
const intl = useIntl()
return (
<div>
{/* Contenu de la page */}
</div>
)
}
// 4. Page principale avec useQueryLoader
const MyFeaturePage: NextPage<PageProps> = ({ viewerSession }) => {
const intl = useIntl()
const [queryReference, loadQuery, disposeQuery] =
useQueryLoader<QueryType>(myFeatureQuery)
React.useEffect(() => {
loadQuery({})
return () => disposeQuery()
}, [loadQuery, disposeQuery])
return (
<Layout navTitle={intl.formatMessage({ id: 'my-feature.title' })}>
<Suspense fallback={<Spinner />}>
{queryReference && (
<MyFeatureContent queryReference={queryReference} />
)}
</Suspense>
</Layout>
)
}
// 5. Guard d'authentification
export const getServerSideProps = withPageAuthRequired
export default MyFeaturePage
Pour les pages simples sans besoin de refetch :
import { useLazyLoadQuery } from 'react-relay'
const SimpleContent: React.FC<{ entityId: string }> = ({ entityId }) => {
const response = useLazyLoadQuery<QueryType>(QUERY, { id: entityId })
const entity = response.node
if (!entity) {
return <NotFound />
}
return <EntityForm entity={entity} />
}
const SimplePage: NextPage<PageProps> = () => {
const router = useRouter()
const { entityId } = router.query as { entityId: string }
return (
<Layout navTitle="Entity">
<Suspense fallback={<Spinner />}>
<SimpleContent entityId={entityId} />
</Suspense>
</Layout>
)
}
// pages/admin-next/entity/[entityId].tsx
import { useRouter } from 'next/router'
export const entityQuery = graphql`
query entityQuery($id: ID!) {
node(id: $id) {
... on Entity {
id
title
}
}
}
`
const EntityPage: NextPage<PageProps> = () => {
const router = useRouter()
const { entityId } = router.query as { entityId: string }
const [queryReference, loadQuery, disposeQuery] =
useQueryLoader<entityQueryType>(entityQuery)
React.useEffect(() => {
if (entityId) {
loadQuery({ id: entityId })
}
return () => disposeQuery()
}, [entityId, loadQuery, disposeQuery])
return (
<Layout navTitle="Entity">
<Suspense fallback={<Spinner />}>
{queryReference && <EntityContent queryReference={queryReference} />}
</Suspense>
</Layout>
)
}
import { useDisclosure } from '@liinkiing/react-hooks'
const { isOpen, onOpen, onClose, onToggle } = useDisclosure(false)
<Button onClick={onOpen}>Ouvrir</Button>
<Modal show={isOpen} onClose={onClose}>...</Modal>
import useIsMobile from '@hooks/useIsMobile'
const isMobile = useIsMobile()
return isMobile ? <MobileView /> : <DesktopView />
import useFeatureFlag from '@shared/hooks/useFeatureFlag'
const isEnabled = useFeatureFlag('feature_name')
if (!isEnabled) return null
import { parseAsString, parseAsInteger, useQueryState, useQueryStates } from 'nuqs'
// Single value
const [search, setSearch] = useQueryState('q')
const [page, setPage] = useQueryState('page', parseAsInteger.withDefault(1))
// Multiple values
const [filters, setFilters] = useQueryStates({
sort: parseAsString.withDefault('date'),
category: parseAsString.withDefault('ALL'),
status: parseAsString,
}, { history: 'push' })
import { useNavBarContext } from '@shared/navbar/NavBar.context'
const { setBreadCrumbItems } = useNavBarContext()
React.useEffect(() => {
setBreadCrumbItems([
{ title: 'Projects', href: '/admin-next/projects' },
{ title: entity.title, href: '' },
])
return () => setBreadCrumbItems([])
}, [entity])
La taille de page est une constante CONNECTION_NODES_PER_PAGE, definie dans un fichier utils.ts a cote du composant de liste (pas inline dans le composant). C'est la convention utilisee sur la plupart des pages du back office (voir components/BackOffice/FooterSocialNetworks/utils.ts, components/BackOffice/UserGroups/utils.ts, etc.).
// components/BackOffice/MyFeature/utils.ts
export const CONNECTION_NODES_PER_PAGE = 20
// components/BackOffice/MyFeature/MyList.tsx
import { usePaginationFragment } from 'react-relay'
import { CONNECTION_NODES_PER_PAGE } from './utils'
const FRAGMENT = graphql`
fragment MyList_query on Query
@argumentDefinitions(
count: { type: "Int!", defaultValue: 20 }
cursor: { type: "String" }
)
@refetchable(queryName: "MyListPaginationQuery") {
items(first: $count, after: $cursor)
@connection(key: "MyList_items") {
edges {
node {
id
...ItemCard_item
}
}
pageInfo {
hasNextPage
}
}
}
`
const MyList: React.FC<Props> = ({ query: queryRef }) => {
const { data, loadNext, hasNext, isLoadingNext } = usePaginationFragment(
FRAGMENT,
queryRef
)
return (
<>
{data.items.edges.map(({ node }) => (
<ItemCard key={node.id} item={node} />
))}
{hasNext && (
<Button
onClick={() => loadNext(CONNECTION_NODES_PER_PAGE)}
isLoading={isLoadingNext}
>
Load more
</Button>
)}
</>
)
}
import { useIntl, FormattedMessage } from 'react-intl'
const intl = useIntl()
// Via hook
const title = intl.formatMessage({ id: 'my-feature.title' })
const withParams = intl.formatMessage(
{ id: 'items.count' },
{ count: 42 }
)
// Via component
<FormattedMessage id="my-feature.description" />
import { ErrorBoundary } from 'react-error-boundary'
const ErrorFallback: React.FC<{ error: Error }> = ({ error }) => (
<Flex direction="column" align="center" p="lg">
<Text color="red.500">Une erreur est survenue</Text>
<Button onClick={() => window.location.reload()}>
Recharger
</Button>
</Flex>
)
const Page: NextPage = () => (
<Layout navTitle="...">
<Suspense fallback={<Spinner />}>
<ErrorBoundary FallbackComponent={ErrorFallback}>
<PageContent />
</ErrorBoundary>
</Suspense>
</Layout>
)
Compiler Relay :
cd admin-next && yarn relay
Verifier les types :
cd admin-next && yarn ts
Tester : https://capco.dev/admin-next/my-feature
ComponentName_fieldName$keyuseFragment pour unwrap les donneesyarn relay executeCONNECTION_NODES_PER_PAGE) dans un utils.ts a cote du composant, pas inlineuseQueryLoader ou useLazyLoadQuerySuspense avec <Spinner /> fallbackLayout avec navTitlegetServerSideProps = withPageAuthRequiredyarn ts