Reorganize GraphQL docs: separate Go backend from frontend Relay client
Move frontend Relay client documentation into relay.md and create new graphql.md dedicated to Go backend patterns. Covers gqlgen schema-first approach, @goModel/@goEnum/@goField directives, connection type patterns, and cursor pagination implementation. - relay.md: Frontend Relay client (environments, compiler, queries, fragments, mutations) - graphql.md: Go backend gqlgen (directives, connection types, pagination schema, keyset pagination) - AGENTS.md: Update documentation references Signed-off-by: Bryan Frimin <bryan@getprobo.com>
This commit is contained in:
@@ -26,8 +26,8 @@ GraphQL and MCP codegen is triggered by `go generate`:
|
||||
## Reference Documentation
|
||||
|
||||
Detailed guides for specific subsystems live in `contrib/claude/`:
|
||||
- [`contrib/claude/relay.md`](contrib/claude/relay.md) — Relay cursor pagination (cursor format, keyset pagination, schema types)
|
||||
- [`contrib/claude/graphql.md`](contrib/claude/graphql.md) — Frontend Relay client (queries, fragments, mutations, pagination)
|
||||
- [`contrib/claude/relay.md`](contrib/claude/relay.md) — Frontend Relay client (queries, fragments, mutations, pagination)
|
||||
- [`contrib/claude/graphql.md`](contrib/claude/graphql.md) — Go GraphQL backend (gqlgen, @goModel, connection types, cursor pagination)
|
||||
- [`contrib/claude/commit.md`](contrib/claude/commit.md) — Commit message conventions
|
||||
- [`contrib/claude/license.md`](contrib/claude/license.md) — ISC license header (all file types)
|
||||
- [`contrib/claude/go-testing.md`](contrib/claude/go-testing.md) — Go test conventions (parallel, require vs assert, naming)
|
||||
|
||||
@@ -1,249 +1,164 @@
|
||||
# GraphQL (Frontend Relay Client)
|
||||
# GraphQL (Go Backend — gqlgen)
|
||||
|
||||
The console app uses [Relay](https://relay.dev/) as its GraphQL client. All GraphQL operations are defined inline with the `graphql` template tag from `relay-runtime` — there are no separate `.graphql` files on the frontend.
|
||||
Schema-first GraphQL using [gqlgen](https://gqlgen.com/). The schema is hand-written; Go types and resolvers are generated.
|
||||
|
||||
## Environments
|
||||
## Connection types and `@goModel`
|
||||
|
||||
Two Relay environments connect to two separate GraphQL APIs:
|
||||
**Always define a custom Go type for connection types** using the `@goModel` directive. The model path points to the `types` package for the relevant API. The `totalCount` field must use `@goField(forceResolver: true)`. Edge types do not need `@goModel`.
|
||||
|
||||
| Environment | Endpoint | Purpose |
|
||||
|-------------|----------|---------|
|
||||
| `coreEnvironment` | `/api/console/v1/graphql` | Main application data |
|
||||
| `iamEnvironment` | `/api/connect/v1/graphql` | Authentication / identity |
|
||||
|
||||
Configured in `apps/console/src/environments.ts`. Each has its own store with 1-minute query cache expiration.
|
||||
|
||||
## Relay compiler
|
||||
|
||||
Config lives in `apps/console/relay.config.json` with two projects (`core`, `iam`) mapped to different source directories and schemas. Generated files go into `__generated__/` directories.
|
||||
|
||||
```sh
|
||||
npm run relay # clean + compile
|
||||
npm run relay-compile # compile only
|
||||
```
|
||||
|
||||
Custom scalar mappings: `Datetime → string`, `GID → string`, `CursorKey → string`, `Duration → string`, `BigInt → number`, `EmailAddr → string`.
|
||||
|
||||
## Colocated queries
|
||||
|
||||
Queries are defined inline in the file that uses them. Route-level queries are preloaded in the router loader before the component renders:
|
||||
|
||||
```tsx
|
||||
// In route definition
|
||||
{
|
||||
path: "vendors",
|
||||
loader: loaderFromQueryLoader(({ organizationId }) =>
|
||||
loadQuery<VendorGraphListQuery>(coreEnvironment, vendorsQuery, {
|
||||
organizationId,
|
||||
snapshotId: null,
|
||||
}),
|
||||
),
|
||||
Component: withQueryRef(
|
||||
lazy(() => import("#/pages/organizations/vendors/VendorsPage")),
|
||||
),
|
||||
```graphql
|
||||
type VendorConnection
|
||||
@goModel(
|
||||
model: "go.probo.inc/probo/pkg/server/api/console/v1/types.VendorConnection"
|
||||
) {
|
||||
totalCount: Int! @goField(forceResolver: true)
|
||||
edges: [VendorEdge!]!
|
||||
pageInfo: PageInfo!
|
||||
}
|
||||
|
||||
// In the component
|
||||
export default function VendorsPage(props: Props) {
|
||||
const data = usePreloadedQuery(vendorsQuery, props.queryRef);
|
||||
// ...
|
||||
type VendorEdge {
|
||||
cursor: CursorKey!
|
||||
node: Vendor!
|
||||
}
|
||||
```
|
||||
|
||||
- `loaderFromQueryLoader` — converts a query loader into a React Router loader, returns `{ queryRef, dispose }`
|
||||
- `withQueryRef` — extracts `queryRef` from loader data and handles cleanup on unmount
|
||||
Without `@goModel`, gqlgen generates a default struct that lacks the custom fields (`ParentID`, `Resolver`, `Filter`) needed by the pagination resolvers.
|
||||
|
||||
For queries that need to run after render (e.g. select dropdowns), use `useLazyLoadQuery` with `fetchPolicy: "network-only"`.
|
||||
## Enums and `@goModel` / `@goEnum`
|
||||
|
||||
## Fragments
|
||||
Map GraphQL enums to existing Go types using `@goModel` on the enum and `@goEnum` on each value:
|
||||
|
||||
Fragments colocate data requirements with the component that reads them:
|
||||
|
||||
```tsx
|
||||
const contactFragment = graphql`
|
||||
fragment VendorContactsTabFragment_contact on VendorContact {
|
||||
id
|
||||
fullName
|
||||
email
|
||||
phone
|
||||
role
|
||||
createdAt
|
||||
updatedAt
|
||||
canUpdate: permission(action: "core:vendor-contact:update")
|
||||
canDelete: permission(action: "core:vendor-contact:delete")
|
||||
}
|
||||
`;
|
||||
|
||||
function ContactRow(props: { contactKey: VendorContactsTabFragment_contact$key }) {
|
||||
const contact = useFragment(contactFragment, props.contactKey);
|
||||
// ...
|
||||
```graphql
|
||||
enum VendorOrderField
|
||||
@goModel(model: "go.probo.inc/probo/pkg/coredata.VendorOrderField") {
|
||||
CREATED_AT
|
||||
@goEnum(value: "go.probo.inc/probo/pkg/coredata.VendorOrderFieldCreatedAt")
|
||||
NAME
|
||||
@goEnum(value: "go.probo.inc/probo/pkg/coredata.VendorOrderFieldName")
|
||||
}
|
||||
```
|
||||
|
||||
### Refetchable fragments
|
||||
## Schema directives
|
||||
|
||||
For lists that support sorting and pagination, use `@refetchable` with `@argumentDefinitions`:
|
||||
| Directive | Target | Purpose |
|
||||
|-----------|--------|---------|
|
||||
| `@goModel(model: "...")` | `OBJECT`, `ENUM`, `INPUT_OBJECT`, `SCALAR`, `INTERFACE`, `UNION` | Map GraphQL type to existing Go type |
|
||||
| `@goEnum(value: "...")` | `ENUM_VALUE` | Map enum value to Go constant |
|
||||
| `@goField(forceResolver: true)` | `FIELD_DEFINITION` | Force a resolver function instead of struct field |
|
||||
| `@goField(name: "...")` | `FIELD_DEFINITION`, `INPUT_FIELD_DEFINITION` | Override Go field name |
|
||||
| `@goField(omittable: true)` | `INPUT_FIELD_DEFINITION` | Use `graphql.Omittable[T]` for distinguishing null vs absent |
|
||||
|
||||
```tsx
|
||||
const vendorContactsFragment = graphql`
|
||||
fragment VendorContactsTabFragment on Vendor
|
||||
@refetchable(queryName: "VendorContactsListQuery")
|
||||
@argumentDefinitions(
|
||||
first: { type: "Int", defaultValue: 50 }
|
||||
order: { type: "VendorContactOrder", defaultValue: null }
|
||||
after: { type: "CursorKey", defaultValue: null }
|
||||
before: { type: "CursorKey", defaultValue: null }
|
||||
last: { type: "Int", defaultValue: null }
|
||||
) {
|
||||
contacts(
|
||||
first: $first
|
||||
after: $after
|
||||
last: $last
|
||||
before: $before
|
||||
orderBy: $order
|
||||
) @connection(key: "VendorContactsTabFragment_contacts") {
|
||||
__id
|
||||
edges {
|
||||
node {
|
||||
...VendorContactsTabFragment_contact
|
||||
}
|
||||
}
|
||||
## Cursor pagination schema types
|
||||
|
||||
Every paginated field uses shared base types plus entity-specific types:
|
||||
|
||||
```graphql
|
||||
type PageInfo {
|
||||
hasNextPage: Boolean!
|
||||
hasPreviousPage: Boolean!
|
||||
startCursor: CursorKey
|
||||
endCursor: CursorKey
|
||||
}
|
||||
|
||||
enum OrderDirection
|
||||
@goModel(model: "go.probo.inc/probo/pkg/page.OrderDirection") {
|
||||
ASC @goEnum(value: "go.probo.inc/probo/pkg/page.OrderDirectionAsc")
|
||||
DESC @goEnum(value: "go.probo.inc/probo/pkg/page.OrderDirectionDesc")
|
||||
}
|
||||
```
|
||||
|
||||
Each entity defines: `enum XxxOrderField`, `input XxxOrder`, `type XxxConnection` (with `@goModel`), `type XxxEdge`.
|
||||
|
||||
Connection fields on parent types use standard Relay arguments:
|
||||
|
||||
```graphql
|
||||
type Organization {
|
||||
vendors(
|
||||
first: Int
|
||||
after: CursorKey
|
||||
last: Int
|
||||
before: CursorKey
|
||||
orderBy: VendorOrder
|
||||
filter: VendorFilter
|
||||
): VendorConnection!
|
||||
}
|
||||
```
|
||||
|
||||
## Go connection type pattern
|
||||
|
||||
Each connection type lives in `types/*_connection.go` and follows this structure:
|
||||
|
||||
```go
|
||||
type (
|
||||
VendorOrderBy OrderBy[coredata.VendorOrderField]
|
||||
|
||||
VendorConnection struct {
|
||||
TotalCount int
|
||||
Edges []*VendorEdge
|
||||
PageInfo PageInfo
|
||||
|
||||
Resolver any
|
||||
ParentID gid.GID
|
||||
}
|
||||
}
|
||||
`;
|
||||
)
|
||||
|
||||
const [data, refetch] = useRefetchableFragment(vendorContactsFragment, vendor);
|
||||
const connectionId = data.contacts.__id;
|
||||
```
|
||||
|
||||
## Pagination
|
||||
|
||||
Use `usePaginationFragment` for cursor-based Relay pagination:
|
||||
|
||||
```tsx
|
||||
const pagination = usePaginationFragment(paginatedVendorsFragment, data.node);
|
||||
const vendors = pagination.data.vendors?.edges.map(edge => edge.node);
|
||||
const connectionId = pagination.data.vendors.__id;
|
||||
```
|
||||
|
||||
The `@connection(key: "...", filters: [...])` directive on the fragment tells Relay how to manage the paginated list in the store. The `filters` array controls which variables affect the connection identity.
|
||||
|
||||
`SortableTable` is the standard component for rendering paginated, sortable lists — it receives `pagination` (with `loadNext`, `hasNext`, `isLoadingNext`) and a `refetch` callback for sorting.
|
||||
|
||||
## Mutations
|
||||
|
||||
### `useMutation`
|
||||
|
||||
Direct Relay hook for simple cases:
|
||||
|
||||
```tsx
|
||||
const [mutate] = useMutation<VendorGraphDeleteMutation>(deleteVendorMutation);
|
||||
```
|
||||
|
||||
### `useMutationWithToasts`
|
||||
|
||||
Custom wrapper that adds toast notifications on success/error:
|
||||
|
||||
```tsx
|
||||
const [createContact, isLoading] = useMutationWithToasts(
|
||||
createContactMutation,
|
||||
{
|
||||
successMessage: __("Contact created successfully."),
|
||||
errorMessage: __("Failed to create contact"),
|
||||
},
|
||||
);
|
||||
|
||||
await createContact({
|
||||
variables: {
|
||||
input: { vendorId, ...cleanData },
|
||||
connections: [connectionId],
|
||||
},
|
||||
onSuccess: () => {
|
||||
dialogRef.current?.close();
|
||||
reset();
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### Store update directives
|
||||
|
||||
Relay directives handle connection updates automatically — no manual store manipulation needed:
|
||||
|
||||
```tsx
|
||||
// Add new edge to the beginning of a connection
|
||||
const createMutation = graphql`
|
||||
mutation CreateVendorMutation($input: CreateVendorInput!, $connections: [ID!]!) {
|
||||
createVendor(input: $input) {
|
||||
vendorEdge @prependEdge(connections: $connections) {
|
||||
node {
|
||||
id
|
||||
name
|
||||
}
|
||||
}
|
||||
func NewVendorConnection(
|
||||
p *page.Page[*coredata.Vendor, coredata.VendorOrderField],
|
||||
parentType any,
|
||||
parentID gid.GID,
|
||||
) *VendorConnection {
|
||||
edges := make([]*VendorEdge, len(p.Data))
|
||||
for i, v := range p.Data {
|
||||
edges[i] = NewVendorEdge(v, p.Cursor.OrderBy.Field)
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
// Remove an edge from a connection
|
||||
const deleteMutation = graphql`
|
||||
mutation DeleteVendorMutation($input: DeleteVendorInput!, $connections: [ID!]!) {
|
||||
deleteVendor(input: $input) {
|
||||
deletedVendorId @deleteEdge(connections: $connections)
|
||||
return &VendorConnection{
|
||||
Edges: edges,
|
||||
PageInfo: *NewPageInfo(p),
|
||||
|
||||
Resolver: parentType,
|
||||
ParentID: parentID,
|
||||
}
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
// Update in-place via fragment spread (no directive needed)
|
||||
const updateMutation = graphql`
|
||||
mutation UpdateContactMutation($input: UpdateVendorContactInput!) {
|
||||
updateVendorContact(input: $input) {
|
||||
vendorContact {
|
||||
...VendorContactsTabFragment_contact
|
||||
}
|
||||
func NewVendorEdge(
|
||||
v *coredata.Vendor,
|
||||
orderBy coredata.VendorOrderField,
|
||||
) *VendorEdge {
|
||||
return &VendorEdge{
|
||||
Cursor: v.CursorKey(orderBy),
|
||||
Node: NewVendor(v),
|
||||
}
|
||||
}
|
||||
`;
|
||||
}
|
||||
```
|
||||
|
||||
The `connections` variable is obtained from the `__id` field on the connection in the parent query/fragment.
|
||||
## Cursor format
|
||||
|
||||
### `useConfirm` for destructive actions
|
||||
|
||||
Destructive mutations (delete) are wrapped with a confirmation dialog:
|
||||
|
||||
```tsx
|
||||
const confirm = useConfirm();
|
||||
|
||||
return () => {
|
||||
confirm(
|
||||
() =>
|
||||
promisifyMutation(mutate)({
|
||||
variables: {
|
||||
input: { vendorId: vendor.id! },
|
||||
connections: [connectionId],
|
||||
},
|
||||
}),
|
||||
{ message: "Confirm deletion..." },
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
## File organization
|
||||
|
||||
GraphQL operations are colocated with the components that use them:
|
||||
Cursors are opaque `CursorKey` scalars. Internally they encode as base64url(JSON):
|
||||
|
||||
```
|
||||
pages/organizations/vendors/
|
||||
VendorsPage.tsx # query + pagination fragment
|
||||
tabs/
|
||||
VendorContactsTab.tsx # refetchable fragment + item fragment
|
||||
VendorComplianceTab.tsx
|
||||
dialogs/
|
||||
CreateContactDialog.tsx # create mutation
|
||||
EditContactDialog.tsx # update mutation
|
||||
|
||||
hooks/graph/
|
||||
VendorGraph.ts # shared queries, mutations, hooks
|
||||
["<entity_global_id>", <sort_field_value>]
|
||||
```
|
||||
|
||||
Shared queries and mutation hooks (used by multiple components) live in `hooks/graph/*.ts`. Component-specific operations are defined inline in the component file.
|
||||
This enables keyset pagination — the database seeks directly to the right position instead of using OFFSET.
|
||||
|
||||
## Keyset pagination
|
||||
|
||||
The database query uses the cursor to build a WHERE clause:
|
||||
|
||||
- `DESC`: rows where `(field <= cursor_value) AND NOT (field = cursor_value AND id > cursor_id)`
|
||||
- `ASC`: rows where `(field >= cursor_value) AND NOT (field = cursor_value AND id < cursor_id)`
|
||||
|
||||
The query fetches `size + 1` (or `size + 2` with a cursor) rows to detect whether more pages exist. `NewPage` trims extra rows and sets `hasNextPage` / `hasPreviousPage`.
|
||||
|
||||
For backward pagination (`last` / `before`), SQL sort direction is reversed, then the result slice is reversed back.
|
||||
|
||||
Default page size is **25** when neither `first` nor `last` is provided.
|
||||
|
||||
## Adding a new paginated field — checklist
|
||||
|
||||
1. **Schema** — add `enum XxxOrderField` (with `@goModel`/`@goEnum`), `input XxxOrder`, `type XxxConnection` (with `@goModel` and `totalCount` using `@goField(forceResolver: true)`), `type XxxEdge`, and the connection field with Relay arguments on the parent type
|
||||
2. **Coredata** — add `*_order_field.go` (with `Column()`, `IsValid()`, marshaling), `CursorKey(field)` method on the entity, and the `LoadAllBy*` query using cursor SQL fragments + `page.NewPage()`
|
||||
3. **API types** — add `*_connection.go` with `OrderBy` alias, connection struct, `NewXxxConnection`, `NewXxxEdge`
|
||||
4. **Resolver** — implement the resolver (authorize, build order, build cursor, call service, build connection)
|
||||
5. **Codegen** — run `go generate` for the relevant API package
|
||||
|
||||
@@ -1,101 +1,249 @@
|
||||
# Relay Cursor Pagination
|
||||
# Relay (Frontend GraphQL Client)
|
||||
|
||||
This project implements [Relay-style cursor pagination](https://relay.dev/graphql/connections.htm) for all list fields across GraphQL APIs.
|
||||
The console app uses [Relay](https://relay.dev/) as its GraphQL client. All GraphQL operations are defined inline with the `graphql` template tag from `relay-runtime` — there are no separate `.graphql` files on the frontend.
|
||||
|
||||
## Schema types
|
||||
## Environments
|
||||
|
||||
Every paginated field uses the same set of types:
|
||||
Two Relay environments connect to two separate GraphQL APIs:
|
||||
|
||||
```graphql
|
||||
type PageInfo {
|
||||
hasNextPage: Boolean!
|
||||
hasPreviousPage: Boolean!
|
||||
startCursor: CursorKey
|
||||
endCursor: CursorKey
|
||||
| Environment | Endpoint | Purpose |
|
||||
|-------------|----------|---------|
|
||||
| `coreEnvironment` | `/api/console/v1/graphql` | Main application data |
|
||||
| `iamEnvironment` | `/api/connect/v1/graphql` | Authentication / identity |
|
||||
|
||||
Configured in `apps/console/src/environments.ts`. Each has its own store with 1-minute query cache expiration.
|
||||
|
||||
## Relay compiler
|
||||
|
||||
Config lives in `apps/console/relay.config.json` with two projects (`core`, `iam`) mapped to different source directories and schemas. Generated files go into `__generated__/` directories.
|
||||
|
||||
```sh
|
||||
npm run relay # clean + compile
|
||||
npm run relay-compile # compile only
|
||||
```
|
||||
|
||||
Custom scalar mappings: `Datetime → string`, `GID → string`, `CursorKey → string`, `Duration → string`, `BigInt → number`, `EmailAddr → string`.
|
||||
|
||||
## Colocated queries
|
||||
|
||||
Queries are defined inline in the file that uses them. Route-level queries are preloaded in the router loader before the component renders:
|
||||
|
||||
```tsx
|
||||
// In route definition
|
||||
{
|
||||
path: "vendors",
|
||||
loader: loaderFromQueryLoader(({ organizationId }) =>
|
||||
loadQuery<VendorGraphListQuery>(coreEnvironment, vendorsQuery, {
|
||||
organizationId,
|
||||
snapshotId: null,
|
||||
}),
|
||||
),
|
||||
Component: withQueryRef(
|
||||
lazy(() => import("#/pages/organizations/vendors/VendorsPage")),
|
||||
),
|
||||
}
|
||||
|
||||
enum OrderDirection {
|
||||
ASC
|
||||
DESC
|
||||
// In the component
|
||||
export default function VendorsPage(props: Props) {
|
||||
const data = usePreloadedQuery(vendorsQuery, props.queryRef);
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
Each entity defines its own order field enum, order input, connection, and edge:
|
||||
- `loaderFromQueryLoader` — converts a query loader into a React Router loader, returns `{ queryRef, dispose }`
|
||||
- `withQueryRef` — extracts `queryRef` from loader data and handles cleanup on unmount
|
||||
|
||||
```graphql
|
||||
enum VendorOrderField {
|
||||
CREATED_AT
|
||||
NAME
|
||||
}
|
||||
For queries that need to run after render (e.g. select dropdowns), use `useLazyLoadQuery` with `fetchPolicy: "network-only"`.
|
||||
|
||||
input VendorOrder {
|
||||
direction: OrderDirection!
|
||||
field: VendorOrderField!
|
||||
}
|
||||
## Fragments
|
||||
|
||||
type VendorConnection {
|
||||
totalCount: Int!
|
||||
edges: [VendorEdge!]!
|
||||
pageInfo: PageInfo!
|
||||
}
|
||||
Fragments colocate data requirements with the component that reads them:
|
||||
|
||||
type VendorEdge {
|
||||
cursor: CursorKey!
|
||||
node: Vendor!
|
||||
```tsx
|
||||
const contactFragment = graphql`
|
||||
fragment VendorContactsTabFragment_contact on VendorContact {
|
||||
id
|
||||
fullName
|
||||
email
|
||||
phone
|
||||
role
|
||||
createdAt
|
||||
updatedAt
|
||||
canUpdate: permission(action: "core:vendor-contact:update")
|
||||
canDelete: permission(action: "core:vendor-contact:delete")
|
||||
}
|
||||
`;
|
||||
|
||||
function ContactRow(props: { contactKey: VendorContactsTabFragment_contact$key }) {
|
||||
const contact = useFragment(contactFragment, props.contactKey);
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
## Field arguments
|
||||
### Refetchable fragments
|
||||
|
||||
Connection fields on parent types always use the standard Relay arguments:
|
||||
For lists that support sorting and pagination, use `@refetchable` with `@argumentDefinitions`:
|
||||
|
||||
```graphql
|
||||
type Organization {
|
||||
vendors(
|
||||
first: Int
|
||||
after: CursorKey
|
||||
last: Int
|
||||
before: CursorKey
|
||||
orderBy: VendorOrder
|
||||
filter: VendorFilter
|
||||
): VendorConnection!
|
||||
}
|
||||
```tsx
|
||||
const vendorContactsFragment = graphql`
|
||||
fragment VendorContactsTabFragment on Vendor
|
||||
@refetchable(queryName: "VendorContactsListQuery")
|
||||
@argumentDefinitions(
|
||||
first: { type: "Int", defaultValue: 50 }
|
||||
order: { type: "VendorContactOrder", defaultValue: null }
|
||||
after: { type: "CursorKey", defaultValue: null }
|
||||
before: { type: "CursorKey", defaultValue: null }
|
||||
last: { type: "Int", defaultValue: null }
|
||||
) {
|
||||
contacts(
|
||||
first: $first
|
||||
after: $after
|
||||
last: $last
|
||||
before: $before
|
||||
orderBy: $order
|
||||
) @connection(key: "VendorContactsTabFragment_contacts") {
|
||||
__id
|
||||
edges {
|
||||
node {
|
||||
...VendorContactsTabFragment_contact
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const [data, refetch] = useRefetchableFragment(vendorContactsFragment, vendor);
|
||||
const connectionId = data.contacts.__id;
|
||||
```
|
||||
|
||||
- `first` / `after` — forward pagination (returns `Head` position)
|
||||
- `last` / `before` — backward pagination (returns `Tail` position)
|
||||
- `orderBy` — optional, defaults to `CREATED_AT` / `DESC`
|
||||
- `filter` — optional, entity-specific filtering
|
||||
## Pagination
|
||||
|
||||
## Cursor format
|
||||
Use `usePaginationFragment` for cursor-based Relay pagination:
|
||||
|
||||
Cursors are opaque `CursorKey` scalars. Internally they encode as base64url(JSON):
|
||||
|
||||
```
|
||||
["<entity_global_id>", <sort_field_value>]
|
||||
```tsx
|
||||
const pagination = usePaginationFragment(paginatedVendorsFragment, data.node);
|
||||
const vendors = pagination.data.vendors?.edges.map(edge => edge.node);
|
||||
const connectionId = pagination.data.vendors.__id;
|
||||
```
|
||||
|
||||
For example, a cursor sorting by `created_at` encodes the entity ID and its `created_at` timestamp. This enables keyset pagination — the database uses the cursor values to seek directly to the right position instead of using OFFSET.
|
||||
The `@connection(key: "...", filters: [...])` directive on the fragment tells Relay how to manage the paginated list in the store. The `filters` array controls which variables affect the connection identity.
|
||||
|
||||
## Keyset pagination
|
||||
`SortableTable` is the standard component for rendering paginated, sortable lists — it receives `pagination` (with `loadNext`, `hasNext`, `isLoadingNext`) and a `refetch` callback for sorting.
|
||||
|
||||
The database query uses the cursor to build a WHERE clause that skips to the correct position:
|
||||
## Mutations
|
||||
|
||||
- For `DESC` ordering: rows where `(field <= cursor_value) AND NOT (field = cursor_value AND id > cursor_id)`
|
||||
- For `ASC` ordering: rows where `(field >= cursor_value) AND NOT (field = cursor_value AND id < cursor_id)`
|
||||
### `useMutation`
|
||||
|
||||
The query fetches `size + 1` (or `size + 2` when a cursor is provided) rows to detect whether more pages exist in either direction. `NewPage` trims the extra rows and sets `hasNextPage` / `hasPreviousPage` accordingly.
|
||||
Direct Relay hook for simple cases:
|
||||
|
||||
For backward pagination (`last` / `before`), the SQL sort direction is reversed, and the result slice is reversed back to the correct order before building edges.
|
||||
```tsx
|
||||
const [mutate] = useMutation<VendorGraphDeleteMutation>(deleteVendorMutation);
|
||||
```
|
||||
|
||||
## Default page size
|
||||
### `useMutationWithToasts`
|
||||
|
||||
When neither `first` nor `last` is provided, the default page size is **25**.
|
||||
Custom wrapper that adds toast notifications on success/error:
|
||||
|
||||
## Adding a new paginated field — checklist
|
||||
```tsx
|
||||
const [createContact, isLoading] = useMutationWithToasts(
|
||||
createContactMutation,
|
||||
{
|
||||
successMessage: __("Contact created successfully."),
|
||||
errorMessage: __("Failed to create contact"),
|
||||
},
|
||||
);
|
||||
|
||||
1. **Schema** — add `enum XxxOrderField`, `input XxxOrder`, `type XxxConnection`, `type XxxEdge`, and the connection field with Relay arguments on the parent type
|
||||
2. **Coredata** — add `*_order_field.go` (with `Column()`, `IsValid()`, marshaling), `CursorKey(field)` method on the entity, and the `LoadAllBy*` query using cursor SQL fragments + `page.NewPage()`
|
||||
3. **API types** — add `*_connection.go` with `OrderBy` alias, connection struct, `NewXxxConnection`, `NewXxxEdge`
|
||||
4. **Resolver** — implement the resolver (authorize, build order, build cursor, call service, build connection)
|
||||
5. **Codegen** — run `go generate` for the relevant API package
|
||||
await createContact({
|
||||
variables: {
|
||||
input: { vendorId, ...cleanData },
|
||||
connections: [connectionId],
|
||||
},
|
||||
onSuccess: () => {
|
||||
dialogRef.current?.close();
|
||||
reset();
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### Store update directives
|
||||
|
||||
Relay directives handle connection updates automatically — no manual store manipulation needed:
|
||||
|
||||
```tsx
|
||||
// Add new edge to the beginning of a connection
|
||||
const createMutation = graphql`
|
||||
mutation CreateVendorMutation($input: CreateVendorInput!, $connections: [ID!]!) {
|
||||
createVendor(input: $input) {
|
||||
vendorEdge @prependEdge(connections: $connections) {
|
||||
node {
|
||||
id
|
||||
name
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
// Remove an edge from a connection
|
||||
const deleteMutation = graphql`
|
||||
mutation DeleteVendorMutation($input: DeleteVendorInput!, $connections: [ID!]!) {
|
||||
deleteVendor(input: $input) {
|
||||
deletedVendorId @deleteEdge(connections: $connections)
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
// Update in-place via fragment spread (no directive needed)
|
||||
const updateMutation = graphql`
|
||||
mutation UpdateContactMutation($input: UpdateVendorContactInput!) {
|
||||
updateVendorContact(input: $input) {
|
||||
vendorContact {
|
||||
...VendorContactsTabFragment_contact
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
```
|
||||
|
||||
The `connections` variable is obtained from the `__id` field on the connection in the parent query/fragment.
|
||||
|
||||
### `useConfirm` for destructive actions
|
||||
|
||||
Destructive mutations (delete) are wrapped with a confirmation dialog:
|
||||
|
||||
```tsx
|
||||
const confirm = useConfirm();
|
||||
|
||||
return () => {
|
||||
confirm(
|
||||
() =>
|
||||
promisifyMutation(mutate)({
|
||||
variables: {
|
||||
input: { vendorId: vendor.id! },
|
||||
connections: [connectionId],
|
||||
},
|
||||
}),
|
||||
{ message: "Confirm deletion..." },
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
## File organization
|
||||
|
||||
GraphQL operations are colocated with the components that use them:
|
||||
|
||||
```
|
||||
pages/organizations/vendors/
|
||||
VendorsPage.tsx # query + pagination fragment
|
||||
tabs/
|
||||
VendorContactsTab.tsx # refetchable fragment + item fragment
|
||||
VendorComplianceTab.tsx
|
||||
dialogs/
|
||||
CreateContactDialog.tsx # create mutation
|
||||
EditContactDialog.tsx # update mutation
|
||||
|
||||
hooks/graph/
|
||||
VendorGraph.ts # shared queries, mutations, hooks
|
||||
```
|
||||
|
||||
Shared queries and mutation hooks (used by multiple components) live in `hooks/graph/*.ts`. Component-specific operations are defined inline in the component file.
|
||||
|
||||
Reference in New Issue
Block a user