Files
probo/contrib/claude/relay.md
Émile Ré 00f5d0c4ba Remove orphaned apps/trust frontend
Compliance portal already embeds and serves the public SPA, so the
old @probo/trust app was unused. Drop its Makefile/CI stubs, Relay
project, CORS port, and the helper that only it imported.

Signed-off-by: Émile Ré <emile@probo.com>
2026-07-22 14:19:41 +02:00

709 lines
28 KiB
Markdown

# Relay (Frontend GraphQL Client)
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.
## Environments
Two Relay environments connect to two separate GraphQL APIs:
| 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 `relay.config.json` at the repo root with three projects (`core`, `iam`, `complianceportal`) mapped to different source directories and schemas. Each project uses `schema` pointing to `base.graphql` and `schemaExtensions` pointing to the `graphql/` directory containing the per-entity schema files. Generated files go into `__generated__/` directories.
```sh
make relay # merge split schemas + clean + compile
```
Custom scalar mappings: `Datetime → string`, `GID → string`, `CursorKey → string`, `Duration → string`, `BigInt → number`, `EmailAddr → string`.
## Naming operations and fragments
Every operation (query, mutation, subscription) and fragment carries its **module-name prefix**: `<ModuleName><Type>` for operations, `<ModuleName>_<localName>` for fragments, where `<ModuleName>` is the file's basename.
```tsx
// PosterHovercard.tsx
query PosterHovercardQuery { ... } // operation: <ModuleName><Type>
fragment PosterHovercard_poster on Poster { ... } // fragment: <ModuleName>_<localName>
```
Relay 21 dropped the **compiler** requirement to prefix names with the filename for non-Haste projects (now opt-in via `enforce_module_name_prefix_for_non_haste`). We keep the prefix as mandatory house style:
- **Operations still require it regardless** — the `relay/graphql-naming` rule (from `eslint-plugin-relay`'s `ts-recommended`) reports any operation whose name doesn't start with the module name. Dropping it only for fragments would split the convention.
- **Uniqueness** — Relay still requires globally-unique operation/fragment names per project; the module prefix is the collision-free scheme that guarantees it.
- **Discoverability** — generated artifact filenames and the `$key` / `$data` types derive from the name, so `PosterHovercard_poster$key` points straight back to its source module.
We set `enforce_module_name_prefix_for_non_haste` in `relay.config.json` so the **compiler** guarantees the convention for fragments too — the lint rule only covers operations and legacy fragment containers, not hooks-based fragments.
`<localName>` is the data the key feeds (the prop minus its `Key` suffix), never a redundant `Fragment` word: a `contactKey` prop reads a `ContactListItem_contact` fragment.
## Colocated queries
Queries are defined inline in the file that uses them. Route-level queries are preloaded in a dedicated `*PageLoader` component before the page renders.
### Route definition
Routes only declare `path`, `Fallback`, and `Component` pointing to a lazy-loaded loader component — no Relay logic in the route itself:
```tsx
// In route file (e.g. findingRoutes.ts)
import { lazy } from "@probo/react-lazy";
import type { AppRoute } from "@probo/routes";
import { PageSkeleton } from "#/components/skeletons/PageSkeleton";
export const findingRoutes = [
{
path: "findings",
Fallback: PageSkeleton,
Component: lazy(
() => import("#/pages/organizations/findings/FindingsPageLoader"),
),
},
] satisfies AppRoute[];
```
### Loader component
The loader component owns the Relay query lifecycle — it calls `useQueryLoader` + `useEffect` to preload, renders a skeleton while waiting, then wraps the real page in `Suspense`:
```tsx
// FindingsPageLoader.tsx
import { Suspense, useEffect } from "react";
import { useQueryLoader } from "react-relay";
import { useParams } from "react-router";
import type { FindingsPageListQuery } from "#/__generated__/core/FindingsPageListQuery.graphql";
import { PageSkeleton } from "#/components/skeletons/PageSkeleton";
import { useOrganizationId } from "#/hooks/useOrganizationId";
import FindingsPage, { findingsPageQuery } from "./FindingsPage";
export default function FindingsPageLoader() {
const organizationId = useOrganizationId();
const { snapshotId } = useParams<{ snapshotId?: string }>();
const [queryRef, loadQuery]
= useQueryLoader<FindingsPageListQuery>(findingsPageQuery);
useEffect(() => {
loadQuery({
organizationId,
snapshotId: snapshotId ?? null,
});
}, [loadQuery, organizationId, snapshotId]);
if (!queryRef) {
return <PageSkeleton />;
}
return (
<Suspense fallback={<PageSkeleton />}>
<FindingsPage queryRef={queryRef} />
</Suspense>
);
}
```
### Page component
The page receives `queryRef` as a prop and reads data with `usePreloadedQuery`:
```tsx
// FindingsPage.tsx
export const findingsPageQuery = graphql`
query FindingsPageListQuery($organizationId: ID!, $snapshotId: ID) {
node(id: $organizationId) {
... on Organization {
...FindingsPageFragment @arguments(snapshotId: $snapshotId)
}
}
}
`;
interface FindingsPageProps {
queryRef: PreloadedQuery<FindingsPageListQuery>;
};
export default function FindingsPage({ queryRef }: FindingsPageProps) {
const data = usePreloadedQuery(findingsPageQuery, queryRef);
// ...
}
```
### `loaderFromQueryLoader` / `withQueryRef` (deprecated)
**Do not use.** Use a `*PageLoader` component with `useQueryLoader` as shown above instead.
## Interaction-triggered queries
When a user interaction (hover, click, open dialog) needs data beyond what the initial page query loaded, use a secondary query with `useQueryLoader` + `usePreloadedQuery`. This starts fetching in the event handler — before the target component renders — so the network request and component rendering overlap instead of running sequentially.
The parent component owns the query lifecycle with `useQueryLoader`, triggers the fetch in the event handler, and passes the query ref down:
```tsx
import { Suspense } from "react";
import { useQueryLoader } from "react-relay";
import type { PosterHovercardQuery as HovercardQueryType } from "#/__generated__/core/PosterHovercardQuery.graphql";
import PosterHovercard, { posterHovercardQuery } from "./PosterHovercard";
function PosterByline({ poster }: Props) {
const data = useFragment(posterBylineFragment, poster);
const [hovercardQueryRef, loadHovercardQuery] =
useQueryLoader<HovercardQueryType>(posterHovercardQuery);
function onBeginHover() {
loadHovercardQuery({ posterId: data.id });
}
return (
<HoverTrigger onBeginHover={onBeginHover}>
{hovercardQueryRef && (
<Suspense fallback={<Spinner />}>
<PosterHovercard queryRef={hovercardQueryRef} />
</Suspense>
)}
</HoverTrigger>
);
}
```
The child component reads data with `usePreloadedQuery`:
```tsx
import { graphql, usePreloadedQuery } from "react-relay";
import type { PreloadedQuery } from "react-relay";
import type { PosterHovercardQuery } from "#/__generated__/core/PosterHovercardQuery.graphql";
export const posterHovercardQuery = graphql`
query PosterHovercardQuery($posterId: ID!) {
node(id: $posterId) {
... on Poster {
...PosterHovercardBodyFragment
}
}
}
`;
interface PosterHovercardProps {
queryRef: PreloadedQuery<PosterHovercardQuery>;
}
export default function PosterHovercard({ queryRef }: PosterHovercardProps) {
const data = usePreloadedQuery(posterHovercardQuery, queryRef);
// ...
}
```
**Do not use `useLazyLoadQuery`** — it defers the fetch until the component renders, adding unnecessary latency. Always prefer `useQueryLoader` + `usePreloadedQuery` so the network request starts in the event handler.
## Fragments
Fragments colocate data requirements with the component that reads them:
```tsx
const contactFragment = graphql`
fragment ContactListItem_contact on ThirdPartyContact {
id
fullName
email
phone
role
createdAt
updatedAt
canUpdate: permission(action: "core:thirdParty-contact:update")
canDelete: permission(action: "core:thirdParty-contact:delete")
}
`;
function ContactListItem(props: { contactKey: ContactListItem_contact$key }) {
const contact = useFragment(contactFragment, props.contactKey);
// ...
}
```
Select `permission(action:)` fields (aliased `canUpdate` / `canDelete`) in the fragment of the component that renders the action, and gate the UI on the resulting boolean. See [`contrib/claude/permissions.md`](permissions.md).
### Required fields (`@required`)
GraphQL schemas mark many fields nullable defensively, but at a given call site you usually **expect** a value to be there. When a field is nullable in the schema but the component cannot meaningfully render without it, annotate it with `@required` so the **generated type is non-null**. This keeps typing honest and consistent: callers stop threading `?.` / `?? ""` / non-null `!` assertions through code that always expects data, and a genuinely-missing value surfaces as a real signal instead of silently rendering an empty UI.
Pick the action by what should happen when the value is actually absent at runtime:
- **`@required(action: THROW)`** — the value is an invariant for this view (e.g. a page's root entity, the `currentTrustCenter` a portal is built around). A null throws on read and propagates to the nearest error boundary (see [`error-handling.md`](error-handling.md)). The field becomes non-null in the type.
- **`@required(action: LOG)`** — a missing value should degrade gracefully rather than crash: the null **bubbles up** to the nearest `@required` ancestor (or makes the fragment/field data null), and Relay logs it. Use when the surrounding UI can render a sensible fallback.
- **`@required(action: NONE)`** — bubble nullability without logging; rarely needed.
```graphql
# Good — the view is built around this entity; THROW makes it non-null
currentTrustCenter @required(action: THROW) {
organization {
name # already String! in the schema — no @required needed
logo { downloadUrl } # legitimately optional — leave nullable
}
}
```
```tsx
// Good — non-null typing falls out of @required; no defensive chaining
const { organization } = data.currentTrustCenter;
const name = organization.name;
const logoUrl = organization.logo?.downloadUrl ?? undefined; // logo stays optional
```
Do **not** reach for `@required` to silence nullability on fields that are *genuinely* optional (an avatar, a logo, a description that may be empty). Those keep their nullable type and get a real empty/fallback state. Likewise, never select a field, mark it `@required(action: THROW)`, and rely on the throw as control flow for an expected-empty case — that is an error path, not a branch. And there is no need to annotate fields the schema already declares non-null (`String!`, `Organization!`).
### Node type guards
A `node(id:)` query resolves to an interface (`Node`), so the page must narrow it
to the concrete type before use. When the `__typename` is not what the view
expects, throw a **typed** error the nearest error boundary can map to the right
state (a not-found page), not a bare `Error`:
```tsx
// Good — NotFoundError is mapped to the 404 state by the boundary
import { NotFoundError } from "#/lib/relay/errors";
const data = usePreloadedQuery<UpdateDetailPageQuery>(updateDetailPageQuery, queryRef);
if (data.node?.__typename !== "MailingListUpdate") {
throw new NotFoundError("Update not found");
}
const update = data.node; // narrowed to MailingListUpdate
```
```tsx
// Bad — an untyped error only renders a generic failure
if (data.node?.__typename !== "MailingListUpdate") {
throw new Error("Update not found");
}
```
This is a client-side invariant, distinct from server field errors (which flow
through `@throwOnFieldError`; see below). See
[`error-handling.md`](error-handling.md).
### Field errors (`@throwOnFieldError`)
To contain a **partial** GraphQL failure (data present, one field errored) to the
component that reads the bad field, annotate the query or fragment with
`@throwOnFieldError`. The field error then throws at the read site
(`usePreloadedQuery` / `useFragment`) and is caught by the nearest `ErrorBoundary`
— on a **fragment** to isolate a section/row, on a **query** for page-level
fields. This only works when the network layer leaves field-level errors in the
response (see the portal fetch in [`error-handling.md`](error-handling.md)).
```graphql
# Good — a field error in this fragment throws at the section's useFragment
fragment RecentUpdatesSection_trustCenter on TrustCenter @throwOnFieldError {
updates(first: 5) { edges { node { id ...MailingListUpdateListItem_update } } }
}
```
### Refetchable fragments
For lists that support sorting and pagination, use `@refetchable` with `@argumentDefinitions`:
```tsx
const thirdPartyContactsFragment = graphql`
fragment ThirdPartyContactsTabFragment on ThirdParty
@refetchable(queryName: "ThirdPartyContactsListQuery")
@argumentDefinitions(
first: { type: "Int", defaultValue: 50 }
order: { type: "ThirdPartyContactOrder", 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: "ThirdPartyContactsTabFragment_contacts") {
__id
edges {
node {
...ThirdPartyContactsTabFragment_contact
}
}
}
}
`;
const [data, refetch] = useRefetchableFragment(thirdPartyContactsFragment, thirdParty);
const connectionId = data.contacts.__id;
```
## Refetch inside a transition
`refetch` (and `loadQuery` / `usePaginationFragment`) **suspends** while the new
data loads. The nearest `Suspense` boundary is usually the **route-level** one,
whose fallback is the whole-page skeleton — so a bare `refetch` (e.g. re-running
a list query when a filter changes) blanks the entire page, toolbar included, on
every change.
Wrap the refetch in `startTransition` (React `useTransition`). A transition keeps
the current UI mounted instead of falling back to the boundary, and exposes
`isPending` so you can scope the loading affordance to just the results — a dim,
an inline spinner, or a small placeholder — never the whole tree. This is the
default for filter/sort refetches; prefer a scoped transition over a page-level
Suspense fallback.
```tsx
// Bad — refetch suspends to the route skeleton; the toolbar and results flash
useEffect(() => { refetch(variables); }, [refetch, variables]);
```
```tsx
// Good — transition keeps the toolbar + current results mounted; only results dim
const [isPending, startTransition] = useTransition();
useEffect(() => {
startTransition(() => {
refetch(variables, { fetchPolicy: "store-or-network" });
});
}, [refetch, variables]);
// scope the loading state to the results container:
<div aria-busy={isPending} className={`… transition-opacity ${isPending ? "opacity-60" : ""}`}>
{/* list */}
</div>
```
See [`state-management.md`](state-management.md#filtering-a-list) for the full
list-filtering pattern (pure URL-state hook + single-owner debounced search).
## Pagination
Use `usePaginationFragment` for cursor-based Relay pagination:
```tsx
const pagination = usePaginationFragment(paginatedThirdPartiesFragment, data.node);
const thirdParties = pagination.data.thirdParties?.edges.map(edge => edge.node);
const connectionId = pagination.data.thirdParties.__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
Every mutation **must** update the Relay store so the UI reflects changes immediately — never rely on a page reload. Use `@appendEdge`/`@prependEdge` for creates, `@deleteEdge` for deletes, node `id` returns for in-place updates, and `updater` functions for complex multi-connection operations.
### `useMutation`
The project mutation primitive — awaitable, preserves every `UseMutationConfig` option, and routes success/error feedback through an injected notifier.
> Import `useMutation` from `#/lib/relay/useMutation`, never from `react-relay`. In compliance-portal this is enforced by a `no-restricted-imports` ESLint rule. See [`contrib/claude/hooks.md`](hooks.md#mutation-hooks).
#### Shared hook, app binding
The mechanics live in `@probo/relay` as `createUseMutation(useNotifier)` — a factory that wraps `react-relay`'s `useMutation` (promise wrapping, `onCompleted`/`onError` dispatch, `errorToast` semantics) but knows nothing about toasts or i18n. Each app binds it once to its own feedback stack via a `MutationNotifier` and re-exports the result as the canonical `useMutation`:
```tsx
// apps/compliance-portal/src/lib/relay/useMutation.ts — the only place feedback is wired
import { createUseMutation, type MutationNotifier } from "@probo/relay";
function useMutationNotifier(): MutationNotifier {
const toast = Toast.useToastManager();
const { t } = useTranslation();
return useMemo<MutationNotifier>(() => ({
notifySuccess: (title) => toast.add({ title, type: "success" }),
notifyError: (error, title) => {
const finalTitle = title ?? t("common.error");
toast.add({ title: finalTitle, description: formatError(finalTitle, error as GraphQLError), type: "error" });
},
}), [toast, t]);
}
export const useMutation = createUseMutation(useMutationNotifier);
```
This keeps `@probo/relay` free of UI and i18n dependencies (the toast system, `react-i18next`, and `formatError` stay in the app), while the awaitable behavior is shared. Pass `MutationFeedback` (`successMessage`, `errorToast`) to control notifications without writing `onCompleted`/`onError` by hand.
#### Naming convention
Name the destructured result of `useMutation` after the **graphql tagged-template variable**, dropping the `Mutation` suffix:
| Tagged node variable | Commit function | In-flight boolean |
|----------------------|-----------------|-------------------|
| `createCookieBannerMutation` | `createCookieBanner` | `isCreating` or `isCreatingCookieBanner` |
| `updateBannerMutation` | `updateBanner` | `isUpdating` |
| `deleteCategoryMutation` | `deleteCategory` | `isDeleting` |
| `activateMutation` | `activate` | `isActivating` |
**Never** use generic names like `commitMutation`, `commit`, or `isInFlight`.
```tsx
// Bad
const [commitMutation, isInFlight] = useMutation<Mutation>(createCookieBannerMutation);
commitMutation({ variables: { ... } });
// Good
const [createCookieBanner, isCreating] = useMutation<Mutation>(createCookieBannerMutation);
createCookieBanner({ variables: { ... } });
```
#### Examples
```tsx
const [deleteThirdParty] = useMutation<ThirdPartyGraphDeleteMutation>(deleteThirdPartyMutation);
```
For mutations with user feedback, queue a toast with Base UI's toast manager (`Toast.useToastManager()`; see [`ui.md`](ui.md#user-feedback-toasts)) from the `onCompleted` / `onError` callbacks:
```tsx
const toast = Toast.useToastManager();
const [createObligation, isCreating] = useMutation<CreateObligationMutation>(createObligationMutation);
const onSubmit = (input: ObligationInput) => {
createObligation({
variables: {
input,
connections: [connectionId],
},
onCompleted() {
toast.add({
title: t("obligations.created"),
type: "success",
});
},
onError(error) {
toast.add({
title: t("common.error"),
description: formatError(t("obligations.createFailed"), error as GraphQLError),
type: "error",
});
},
});
};
```
### `useMutationWithToasts` (deprecated)
**Do not use.** Use `useMutation` and queue feedback with Base UI's toast manager (see [`ui.md`](ui.md#user-feedback-toasts)).
### `promisifyMutation` (deprecated)
**Do not use.** Use `useMutation` with `onCompleted`/`onError` callbacks instead of wrapping in a promise.
### Store update directives
Relay directives handle connection updates automatically — no manual store manipulation needed.
#### Connection setup
Any connection that a mutation will add to or remove from **must** have a `@connection` directive. If the mutation needs the connection ID in the same fragment, expose `__id`; otherwise derive it with `ConnectionHandler.getConnectionID`:
```tsx
const fragment = graphql`
fragment CategorySectionFragment on CookieCategory {
id
cookies(first: 100, orderBy: { field: CREATED_AT, direction: ASC })
@connection(key: "CategorySection_cookies", filters: [])
@required(action: THROW) {
__id
edges {
node {
id
...EditCookieRowFragment
}
}
}
}
`;
const category = useFragment(fragment, categoryKey);
const connectionId = category.cookies.__id;
```
#### `filters` on `@connection`
By default Relay treats every non-pagination argument (`first`, `last`, `after`, `before` are excluded) as a **filter** and encodes its value into the connection's store identity. This means `ConnectionHandler.getConnection(record, key)` will fail to find the connection unless the exact same filter values are passed as a third argument.
**Use `filters: []`** when the connection has fixed arguments (e.g. a hardcoded `orderBy`) and there is only ever one instance of the connection per parent node. This is the common case — it keeps `ConnectionHandler.getConnection` and `getConnectionID` simple:
```graphql
# Good — single fixed ordering, no filtered variants
cookies(first: 100, orderBy: { field: CREATED_AT, direction: ASC })
@connection(key: "CategorySection_cookies", filters: [])
```
**List specific filter arguments** when the same connection is rendered with different filter values and you need Relay to maintain separate lists in the store (e.g. a table with user-selectable sorting or status filters):
```graphql
# Good — user can change the status filter, each variant is a separate list
tasks(first: 50, status: $status, orderBy: $order)
@connection(key: "TaskList_tasks", filters: ["status"])
```
When `filters` includes an argument, `ConnectionHandler.getConnection` requires matching filter values:
```tsx
const conn = ConnectionHandler.getConnection(record, "TaskList_tasks", {
status: "OPEN",
});
```
**Never omit `filters`** — rely on the explicit list rather than Relay's default (all non-pagination args), which silently breaks `ConnectionHandler` lookups and `updater` functions.
#### Connection ID from outside the subtree
When the mutation is triggered from a component that doesn't have access to the connection's `__id` (e.g. a sibling's child rather than a direct descendant), derive the connection ID with `ConnectionHandler.getConnectionID`:
```tsx
import { ConnectionHandler } from "relay-runtime";
const connectionId = ConnectionHandler.getConnectionID(
parentNodeId, // the store ID of the node that owns the connection
"CategorySection_cookies", // the @connection key
);
```
This is useful for dialogs, drawers, or other components rendered outside the subtree that reads the connection.
#### Directive examples
```tsx
// Add new edge to a connection
const createMutation = graphql`
mutation CreateThirdPartyMutation($input: CreateThirdPartyInput!, $connections: [ID!]!) {
createThirdParty(input: $input) {
thirdPartyEdge @prependEdge(connections: $connections) {
node {
id
name
}
}
}
}
`;
// Remove an edge from a connection
const deleteMutation = graphql`
mutation DeleteThirdPartyMutation($input: DeleteThirdPartyInput!, $connections: [ID!]!) {
deleteThirdParty(input: $input) {
deletedThirdPartyId @deleteEdge(connections: $connections)
}
}
`;
// Update in-place (Relay matches by id — no directive needed)
const updateMutation = graphql`
mutation UpdateContactMutation($input: UpdateThirdPartyContactInput!) {
updateThirdPartyContact(input: $input) {
thirdPartyContact {
...ThirdPartyContactsTabFragment_contact
}
}
}
`;
```
The `connections` variable is obtained from the `__id` field on the connection in the parent query/fragment.
#### Fragment spreads in create mutations
When a create mutation returns a new edge, its `node` selection **must** include all fragment spreads used by the list that renders it. This ensures the store has every field the UI needs to render the new item without a refetch:
```tsx
// Bad — missing fragment spread, child components will have missing data
cookieEdge @appendEdge(connections: $connections) {
node { id name duration description }
}
// Good — spreads the same fragment the list uses to render each item
cookieEdge @appendEdge(connections: $connections) {
node { id name duration description ...EditCookieRowFragment }
}
```
#### `updater` for complex store changes
When a single mutation affects multiple connections (e.g. moving an item between two lists) and the server payload doesn't return both an edge and a deletedId, use an `updater` function with `ConnectionHandler`:
```tsx
import { ConnectionHandler } from "relay-runtime";
moveCookie({
variables: { input: { cookieId, targetCookieCategoryId: targetId } },
updater(store) {
const source = store.get(sourceCategoryId);
if (source) {
const sourceConn = ConnectionHandler.getConnection(source, "CategorySection_cookies");
if (sourceConn) ConnectionHandler.deleteNode(sourceConn, cookieId);
}
const target = store.get(targetId);
if (target) {
const targetConn = ConnectionHandler.getConnection(target, "CategorySection_cookies");
if (targetConn) {
const node = store.get(cookieId);
if (node) {
const edge = ConnectionHandler.createEdge(store, targetConn, node, "CookieEdge");
ConnectionHandler.insertEdgeAfter(targetConn, edge);
}
}
}
},
});
```
Prefer declarative directives (`@appendEdge`, `@deleteEdge`) whenever possible; only fall back to `updater` when the operation cannot be expressed with directives alone.
### `useConfirm` for destructive actions
Destructive mutations (delete) are wrapped with a confirmation dialog:
```tsx
const confirm = useConfirm();
const [deleteThirdParty] = useMutation<DeleteThirdPartyMutation>(deleteThirdPartyMutation);
return () => {
confirm(
() =>
new Promise<void>((resolve) => {
deleteThirdParty({
variables: {
input: { thirdPartyId: thirdParty.id! },
connections: [connectionId],
},
onCompleted() {
resolve();
},
onError() {
resolve();
},
});
}),
{ message: "Confirm deletion..." },
);
};
```
## File organization
GraphQL operations are colocated with the components that use them. See [`contrib/claude/app-arborescence.md`](app-arborescence.md) for the full folder layout.
```
pages/organizations/third-parties/
ThirdPartiesPage.tsx # query + pagination fragment
_components/
CreateContactDialog.tsx # create mutation
EditContactDialog.tsx # update mutation
ThirdPartyContactListItem.tsx # connection-item fragment
contacts/
ThirdPartyContactsPage.tsx # refetchable fragment + list section
ThirdPartyContactsSection.tsx # section fragment
```
Component-specific operations (queries, fragments, mutations) are defined inline in the component file that uses them. Shared sub-components live in `_components/` next to the page (scoped to the nearest common ancestor).