diff --git a/apps/console/src/pages/organizations/cookie-banners/configuration/cookies/_components/CategorySection.tsx b/apps/console/src/pages/organizations/cookie-banners/configuration/cookies/_components/CategorySection.tsx index 4ade1414f..28f749eba 100644 --- a/apps/console/src/pages/organizations/cookie-banners/configuration/cookies/_components/CategorySection.tsx +++ b/apps/console/src/pages/organizations/cookie-banners/configuration/cookies/_components/CategorySection.tsx @@ -33,7 +33,7 @@ import { } from "@probo/ui"; import { useState } from "react"; import { useFragment, useMutation } from "react-relay"; -import { graphql } from "relay-runtime"; +import { ConnectionHandler, graphql } from "relay-runtime"; import type { CategorySectionCreateCookieMutation } from "#/__generated__/core/CategorySectionCreateCookieMutation.graphql"; import type { CategorySectionDeleteCookieMutation } from "#/__generated__/core/CategorySectionDeleteCookieMutation.graphql"; @@ -58,7 +58,10 @@ export const categorySectionFragment = graphql` name description kind - cookies(first: 100, orderBy: { field: CREATED_AT, direction: ASC }) @required(action: THROW) { + cookies(first: 100, orderBy: { field: CREATED_AT, direction: ASC }) + @connection(key: "CategorySection_cookies") + @required(action: THROW) { + __id edges { node { id @@ -109,14 +112,16 @@ const updateCategoryMutation = graphql` const createCookieMutation = graphql` mutation CategorySectionCreateCookieMutation( $input: CreateCookieInput! + $connections: [ID!]! ) { createCookie(input: $input) { - cookieEdge { + cookieEdge @appendEdge(connections: $connections) { node { id name duration description + ...EditCookieRowFragment } } cookieBanner { @@ -158,9 +163,10 @@ const updateCookieMutation = graphql` const deleteCookieMutation = graphql` mutation CategorySectionDeleteCookieMutation( $input: DeleteCookieInput! + $connections: [ID!]! ) { deleteCookie(input: $input) { - deletedCookieId + deletedCookieId @deleteEdge(connections: $connections) cookieBanner { id latestVersion { @@ -225,6 +231,7 @@ export function CategorySection({ categoryKey, onDelete }: CategorySectionProps) const [editingCookieId, setEditingCookieId] = useState(null); const [isAddingCookie, setIsAddingCookie] = useState(false); + const cookiesConnectionId = category.cookies.__id; const cookies = category.cookies.edges.map(e => e.node); const isMutating = isUpdating || isCreating || isUpdatingCookie; @@ -276,6 +283,7 @@ export function CategorySection({ categoryKey, onDelete }: CategorySectionProps) duration: cookie.duration, description: cookie.description, }, + connections: [cookiesConnectionId], }, onCompleted(_response, errors) { if (errors?.length) { @@ -360,6 +368,7 @@ export function CategorySection({ categoryKey, onDelete }: CategorySectionProps) deleteCookie({ variables: { input: { cookieId }, + connections: [cookiesConnectionId], }, onCompleted(_response, errors) { if (errors?.length) { @@ -400,6 +409,38 @@ export function CategorySection({ categoryKey, onDelete }: CategorySectionProps) targetCookieCategoryId: targetCategoryId, }, }, + updater(store) { + const sourceCategory = store.get(category.id); + if (sourceCategory) { + const sourceConn = ConnectionHandler.getConnection( + sourceCategory, + "CategorySection_cookies", + ); + if (sourceConn) { + ConnectionHandler.deleteNode(sourceConn, cookieId); + } + } + + const targetCategory = store.get(targetCategoryId); + if (targetCategory) { + const targetConn = ConnectionHandler.getConnection( + targetCategory, + "CategorySection_cookies", + ); + if (targetConn) { + const cookieRecord = store.get(cookieId); + if (cookieRecord) { + const newEdge = ConnectionHandler.createEdge( + store, + targetConn, + cookieRecord, + "CookieEdge", + ); + ConnectionHandler.insertEdgeAfter(targetConn, newEdge); + } + } + } + }, onCompleted(_response, errors) { if (errors?.length) { toast({ diff --git a/contrib/claude/relay.md b/contrib/claude/relay.md index 4c196ee73..bf9e5cd69 100644 --- a/contrib/claude/relay.md +++ b/contrib/claude/relay.md @@ -262,6 +262,8 @@ The `@connection(key: "...", filters: [...])` directive on the fragment tells Re ## 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` Direct Relay hook for simple cases. @@ -335,10 +337,51 @@ const onSubmit = (formData: FormData) => { ### Store update directives -Relay directives handle connection updates automatically — no manual store manipulation needed: +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 and expose `__id`: ```tsx -// Add new edge to the beginning of a connection +const fragment = graphql` + fragment CategorySectionFragment on CookieCategory { + id + cookies(first: 100, orderBy: { field: CREATED_AT, direction: ASC }) + @connection(key: "CategorySection_cookies") + @required(action: THROW) { + __id + edges { + node { + id + ...EditCookieRowFragment + } + } + } + } +`; + +const category = useFragment(fragment, categoryKey); +const connectionId = category.cookies.__id; +``` + +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 CreateVendorMutation($input: CreateVendorInput!, $connections: [ID!]!) { createVendor(input: $input) { @@ -361,7 +404,7 @@ const deleteMutation = graphql` } `; -// Update in-place via fragment spread (no directive needed) +// Update in-place (Relay matches by id — no directive needed) const updateMutation = graphql` mutation UpdateContactMutation($input: UpdateVendorContactInput!) { updateVendorContact(input: $input) { @@ -375,6 +418,55 @@ const updateMutation = graphql` 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: