diff --git a/apps/compliance-portal/package.json b/apps/compliance-portal/package.json index 38c55881b..251e19592 100644 --- a/apps/compliance-portal/package.json +++ b/apps/compliance-portal/package.json @@ -10,12 +10,16 @@ "preview": "vite preview" }, "dependencies": { + "@base-ui/react": "^1.6.0", + "@probo/helpers": "1.0.0", "@probo/react-lazy": "1.0.0", + "@probo/relay": "1.0.0", "@probo/routes": "1.0.0", "@probo/ui": "1.0.0", - "clsx": "^2.1.1", + "i18next": "^26.3.1", "react": "^19.2.7", "react-dom": "^19.2.7", + "react-i18next": "^17.0.8", "react-relay": "^21.0.1", "react-router": "^8.0.0", "relay-runtime": "^21.0.1" diff --git a/apps/compliance-portal/src/lib/relay/useMutation.ts b/apps/compliance-portal/src/lib/relay/useMutation.ts new file mode 100644 index 000000000..fc860e805 --- /dev/null +++ b/apps/compliance-portal/src/lib/relay/useMutation.ts @@ -0,0 +1,53 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +import { Toast } from "@base-ui/react/toast"; +import { formatError, type GraphQLError } from "@probo/helpers"; +import { createUseMutation, type MutationNotifier } from "@probo/relay"; +import { useMemo } from "react"; +import { useTranslation } from "react-i18next"; + +/** + * Binds the shared awaitable useMutation (`@probo/relay`) to this app's + * feedback stack: Base UI toasts, i18next titles, and `formatError` + * descriptions. This is the only place those opinions are wired. + * + * Always import useMutation from `#/lib/relay/useMutation` — never useMutation + * from react-relay. + */ +function useMutationNotifier(): MutationNotifier { + const toast = Toast.useToastManager(); + const { t } = useTranslation(); + + return useMemo( + () => ({ + 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 type { MutationFeedback } from "@probo/relay"; + +export const useMutation = createUseMutation(useMutationNotifier); diff --git a/contrib/claude/app-arborescence.md b/contrib/claude/app-arborescence.md index 68d632b02..fec2ce642 100644 --- a/contrib/claude/app-arborescence.md +++ b/contrib/claude/app-arborescence.md @@ -344,7 +344,7 @@ pages/organizations/third-parties/_components/ThirdPartyContactListItem.tsx ## `_lib` folder -Non-component code scoped to a subtree — hooks, utilities, constants, types — lives in a `_lib/` folder next to the pages that use it. The same hoisting rule as `_components/` applies: shared helpers move to the nearest common ancestor's `_lib/`; truly global helpers live in `src/lib/`. Files in `_lib/` use camelCase (`useThirdPartyFilters.ts`, `formatCurrency.ts`, `constants.ts`). +Non-component code scoped to a subtree — hooks, utilities, constants, types — lives in a `_lib/` folder next to the pages that use it. The same hoisting rule as `_components/` applies: shared helpers move to the nearest common ancestor's `_lib/`; truly global helpers live in `src/lib/`. Files in `_lib/` use camelCase (`useThirdPartyFilters.ts`, `formatCurrency.ts`, `constants.ts`). See [`contrib/claude/hooks.md`](hooks.md) for custom-hook conventions. ```text // Good — feature-scoped helpers under _lib @@ -356,6 +356,25 @@ pages/organizations/third-parties/ ThirdPartyListItem.tsx ``` +## No barrel files + +App code imports every module by its **explicit path** (`#/lib/relay/useMutation`, `#/lib/http/endpoint`). Do **not** add `index.ts` re-export barrels inside an app's `src/` to shorten import paths. + +`index.ts` barrels are reserved for **package public entrypoints** (`packages/*`), where they define a real published API surface (`@probo/ui`, `@probo/relay`, …). An app has no published surface, so a barrel hides nothing — it only adds an indirection hop and invites Vite/tree-shaking and circular-import problems. + +```text +// Bad — barrel inside the app just to shorten a path +src/lib/relay/ + index.ts # export { useMutation } from "./useMutation" + useMutation.ts +// import { useMutation } from "#/lib/relay" + +// Good — import the module directly; no index.ts +src/lib/relay/ + useMutation.ts +// import { useMutation } from "#/lib/relay/useMutation" +``` + ## `_locales` folder Translations are i18next catalogs in a `_locales/` folder, **one file per locale**, named by locale tag: `en-US.json`, `fr-FR.json`. See [`contrib/claude/i18n.md`](i18n.md) for key conventions and setup. diff --git a/contrib/claude/hooks.md b/contrib/claude/hooks.md new file mode 100644 index 000000000..a777bc5e2 --- /dev/null +++ b/contrib/claude/hooks.md @@ -0,0 +1,158 @@ +# Custom hooks + +Custom hooks encapsulate reusable behavior (data wiring, derived state, event logic). This guide covers **where they live**, **how they are named**, and the **mutation hook pattern** — an awaitable wrapper over Relay's `useMutation` that preserves every option and automates error feedback. + +## Related guides + +| Topic | Guide | +|-------|--------| +| `_lib` / `_components` placement, hoisting | [`contrib/claude/app-arborescence.md`](app-arborescence.md) | +| Mutations, store updates, connection directives | [`contrib/claude/relay.md`](relay.md) | +| Toasts, user feedback | [`contrib/claude/ui.md`](ui.md#user-feedback-toasts) | +| i18next translation keys | [`contrib/claude/i18n.md`](i18n.md) | + +## Placement + +- **Feature-scoped hooks** live in the feature's `_lib/` folder, next to the pages that use them (`pages/organizations/measures/_lib/useDeleteMeasure.ts`). +- **Shared hooks** used across features are hoisted to the **nearest common ancestor's** `_lib/`, and only **app-wide** primitives (used everywhere) live in the top-level `src/lib/`. +- Promote a hook **when a second feature needs it**, not preemptively — the same rule as `_components/` (see [`app-arborescence.md`](app-arborescence.md)). + +```text +// Feature-scoped +pages/organizations/measures/_lib/useDeleteMeasure.ts + +// Shared across a feature area +pages/organizations/_lib/useOrganizationId.ts + +// App-wide primitive +src/lib/relay/useMutation.ts +``` + +## Shape and naming + +- **One primary hook per file**; the file is camelCase and matches the hook name (`useDeleteMeasure.ts` → `useDeleteMeasure`). +- Hooks are **`function` declarations**, named `use…` (see [`react-components.md`](react-components.md#component-shape)). +- Colocate a hook's `graphql` operation in the same file. +- A hook returns either a value, or a tuple when it mirrors a React/Relay primitive (`[commit, isInFlight]`). + +## Mutation hooks + +All mutations go through the shared **`useMutation`** primitive. Its mechanics live in `@probo/relay` as the `createUseMutation(useNotifier)` factory, and each app binds it once in `src/lib/relay/useMutation.ts`. It wraps Relay's `useMutation` to: + +1. Return an **awaitable** commit that resolves with the mutation **response** (so callers can `await` and continue only on success). +2. **Preserve every `UseMutationConfig` option** (`variables`, `connections`, `updater`, `optimisticResponse`, `onCompleted`, `onError`, …) by spreading the caller's config. +3. **Automate feedback**: on failure it notifies (via the app's injected `MutationNotifier` — Base UI toast + `formatError`) **and** rejects the promise — controllable per call through a `MutationFeedback` options object. + +### Always import `useMutation` from `#/lib/relay/useMutation` + +Our `useMutation` intentionally shadows `react-relay`'s. **Import it only from `#/lib/relay/useMutation`; never import `useMutation` from `react-relay` directly** (enforced in compliance-portal by a `no-restricted-imports` ESLint rule). This guarantees one consistent entrypoint with awaitable results and automatic error handling everywhere. + +```ts +// Bad — raw Relay hook (no await, no auto error handling) +import { useMutation } from "react-relay"; + +// Good — the project primitive +import { useMutation } from "#/lib/relay/useMutation"; +``` + +### The primitive: shared factory + app binding + +The factory lives in `@probo/relay` and stays free of UI/i18n dependencies — it delegates rendering to an injected `MutationNotifier` (`createUseMutation` source: [`packages/relay/src/useMutation.ts`](../../packages/relay/src/useMutation.ts)). The app binds it once to its own toast + i18n + `formatError` stack: + +```ts +// src/lib/relay/useMutation.ts — the only place feedback is wired +import { Toast } from "@base-ui/react/toast"; +import { formatError, type GraphQLError } from "@probo/helpers"; +import { createUseMutation, type MutationNotifier } from "@probo/relay"; +import { useMemo } from "react"; +import { useTranslation } from "react-i18next"; + +function useMutationNotifier(): MutationNotifier { + const toast = Toast.useToastManager(); + const { t } = useTranslation(); + return useMemo( + () => ({ + 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 type { MutationFeedback } from "@probo/relay"; + +export const useMutation = createUseMutation(useMutationNotifier); +``` + +### Domain mutation hook (colocated `_lib/`) + +A feature hook wraps the primitive with its operation and default feedback. Name the hook after the action; name the destructured commit function after the tagged node minus `Mutation` (see [`relay.md`](relay.md#naming-convention)). + +```ts +// pages/organizations/measures/_lib/useDeleteMeasure.ts +import { graphql } from "relay-runtime"; +import { useTranslation } from "react-i18next"; + +import { useMutation } from "#/lib/relay/useMutation"; +import type { DeleteMeasureMutation } from "#/__generated__/core/DeleteMeasureMutation.graphql"; + +const deleteMeasureMutation = graphql` + mutation DeleteMeasureMutation($input: DeleteMeasureInput!, $connections: [ID!]!) { + deleteMeasure(input: $input) { + deletedMeasureId @deleteEdge(connections: $connections) + } + } +`; + +export function useDeleteMeasure() { + const { t } = useTranslation(); + return useMutation(deleteMeasureMutation, { + successMessage: t("measures.deleted"), + errorToast: t("measures.deleteFailed"), + }); +} +``` + +### Usage — await the result + +```tsx +const [deleteMeasure, isDeleting] = useDeleteMeasure(); + +// Default: awaits the response; on failure it toasts AND throws. +async function onConfirm() { + await deleteMeasure({ variables: { input: { measureId }, connections: [connectionId] } }); + navigate(".."); // only runs on success +} + +// Opt out of the auto-toast to handle the error yourself: +try { + const result = await deleteMeasure({ variables }, { errorToast: false }); + // use result.deleteMeasure.deletedMeasureId … +} catch (error) { + // custom handling +} +``` + +### Do / don't + +```text +// Bad — legacy wrappers (removed in v2) +useMutationWithToasts(...) // resolves to void, loses the response; v1 toast + __ +useMutationWithIncrement(...) // callback-style, not awaitable +promisifyMutation(commit) // standalone wrapper, disconnected from the hook + +// Bad — importing the raw Relay hook +import { useMutation } from "react-relay"; + +// Good — one primitive, awaitable, options preserved, auto error handling +import { useMutation } from "#/lib/relay/useMutation"; +``` + +Prefer the declarative store directives (`@appendEdge`, `@deleteEdge`) and the patterns in [`relay.md`](relay.md) for *what* the mutation does to the store; this hook only governs *how* it is invoked and how its errors surface. diff --git a/contrib/claude/relay.md b/contrib/claude/relay.md index c7e7ecd7c..959604e7b 100644 --- a/contrib/claude/relay.md +++ b/contrib/claude/relay.md @@ -288,7 +288,34 @@ Every mutation **must** update the Relay store so the UI reflects changes immedi ### `useMutation` -Direct Relay hook for simple cases. +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(() => ({ + 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 diff --git a/eslint.config.mjs b/eslint.config.mjs index 9e6f9e89b..33d8248fa 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -5,7 +5,7 @@ import { defineConfig, globalIgnores } from "eslint/config"; // sets below; everything else is ignored so a bare `eslint .` keeps the same // scope as the previous per-workspace configs. const appDirs = ["apps/console/**", "apps/trust/**", "apps/compliance-portal/**"]; -const reactDirs = [...appDirs, "packages/ui/**"]; +const reactDirs = [...appDirs, "packages/ui/**", "packages/relay/**", "packages/routes/**"]; const lintedDirs = [...reactDirs, "packages/eslint-config/**"]; export default defineConfig([ @@ -25,8 +25,6 @@ export default defineConfig([ "packages/n8n-node/**", "packages/prosemirror/**", "packages/react-lazy/**", - "packages/relay/**", - "packages/routes/**", "packages/tsconfig/**", ]), { @@ -48,6 +46,28 @@ export default defineConfig([ files: appDirs, extends: [configs.relay], }, + { + // compliance-portal mutates through the awaitable useMutation bound in + // #/lib/relay/useMutation (over @probo/relay's createUseMutation), never + // react-relay's useMutation directly. Scoped to this app only: console and + // trust still use react-relay's useMutation. + files: ["apps/compliance-portal/**"], + rules: { + "no-restricted-imports": [ + "error", + { + paths: [ + { + name: "react-relay", + importNames: ["useMutation"], + message: + "Use useMutation from #/lib/relay/useMutation, not react-relay.", + }, + ], + }, + ], + }, + }, { files: reactDirs, ignores: ["packages/ui/tailwind.config.js"], diff --git a/package-lock.json b/package-lock.json index ea6ce28a2..97e9c62f4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -29,12 +29,15 @@ "name": "@probo/compliance-portal", "version": "0.0.0", "dependencies": { + "@base-ui/react": "^1.6.0", + "@probo/helpers": "1.0.0", "@probo/react-lazy": "1.0.0", "@probo/routes": "1.0.0", "@probo/ui": "1.0.0", - "clsx": "^2.1.1", + "i18next": "^26.3.1", "react": "^19.2.7", "react-dom": "^19.2.7", + "react-i18next": "^17.0.8", "react-relay": "^21.0.1", "react-router": "^8.0.0", "relay-runtime": "^21.0.1" @@ -570,6 +573,66 @@ "node": ">=6.9.0" } }, + "node_modules/@base-ui/react": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@base-ui/react/-/react-1.6.0.tgz", + "integrity": "sha512-/jzjTWJYXhRFO45Bev9lc3cHbmjzCMpUqbMZ2AgKy/z25mY9B6shGSNcXcjQar9n5doM0KYW1W8fcFv2jZBuMw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.29.2", + "@base-ui/utils": "0.3.1", + "@floating-ui/react-dom": "^2.1.8", + "@floating-ui/utils": "^0.2.11", + "use-sync-external-store": "^1.6.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@date-fns/tz": "^1.2.0", + "@types/react": "^17 || ^18 || ^19", + "date-fns": "^4.0.0", + "react": "^17 || ^18 || ^19", + "react-dom": "^17 || ^18 || ^19" + }, + "peerDependenciesMeta": { + "@date-fns/tz": { + "optional": true + }, + "@types/react": { + "optional": true + }, + "date-fns": { + "optional": true + } + } + }, + "node_modules/@base-ui/utils": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/@base-ui/utils/-/utils-0.3.1.tgz", + "integrity": "sha512-gFFiltORVmW/N6IILTGxizP3PBpVpysqML1ALY5Vk0mH+7faVkCknOU31goYHN5Aoek2dkjxva1XOD2Ce9WuIg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.29.2", + "@floating-ui/utils": "^0.2.11", + "reselect": "^5.2.0", + "use-sync-external-store": "^1.6.0" + }, + "peerDependencies": { + "@types/react": "^17 || ^18 || ^19", + "react": "^17 || ^18 || ^19", + "react-dom": "^17 || ^18 || ^19" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@bcoe/v8-coverage": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", @@ -12575,6 +12638,15 @@ "dev": true, "license": "MIT" }, + "node_modules/html-parse-stringify": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz", + "integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==", + "license": "MIT", + "dependencies": { + "void-elements": "3.1.0" + } + }, "node_modules/html-to-text": { "version": "9.0.5", "resolved": "https://registry.npmjs.org/html-to-text/-/html-to-text-9.0.5.tgz", @@ -12655,6 +12727,34 @@ "ms": "^2.0.0" } }, + "node_modules/i18next": { + "version": "26.3.1", + "resolved": "https://registry.npmjs.org/i18next/-/i18next-26.3.1.tgz", + "integrity": "sha512-txQqd5EULsqEh9OJqRH15aCaOuy/nLJyhw5EHCSKLKJE1aBbb3Zve2+uQIxgWhPm1QqUQoWyQBm2kfmmIrzkcQ==", + "funding": [ + { + "type": "individual", + "url": "https://www.locize.com/i18next" + }, + { + "type": "individual", + "url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project" + }, + { + "type": "individual", + "url": "https://www.locize.com" + } + ], + "license": "MIT", + "peerDependencies": { + "typescript": "^5 || ^6" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, "node_modules/ibm-cloud-sdk-core": { "version": "5.5.0", "resolved": "https://registry.npmjs.org/ibm-cloud-sdk-core/-/ibm-cloud-sdk-core-5.5.0.tgz", @@ -17360,6 +17460,33 @@ "react": "^16.8.0 || ^17 || ^18 || ^19" } }, + "node_modules/react-i18next": { + "version": "17.0.8", + "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.0.8.tgz", + "integrity": "sha512-0ooKbGLU8JXhe1zwpQUWIeXSgLPOfwJmgheWRIUpcoA0CpyabpGhayjdG+/eA5esC1AQ8h2jWpXjJfzQzeDOCw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.29.2", + "html-parse-stringify": "^3.0.1", + "use-sync-external-store": "^1.6.0" + }, + "peerDependencies": { + "i18next": ">= 26.2.0", + "react": ">= 16.8.0", + "typescript": "^5 || ^6" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + }, + "react-native": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, "node_modules/react-intersection-observer": { "version": "9.16.0", "resolved": "https://registry.npmjs.org/react-intersection-observer/-/react-intersection-observer-9.16.0.tgz", @@ -17793,6 +17920,12 @@ "license": "MIT", "peer": true }, + "node_modules/reselect": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.2.0.tgz", + "integrity": "sha512-AgZ3UOZm3YndfrJ4OYjgrT7bmCm/1iqkjvEfH/oYjzh6PD2qw4QuT3jjnXIrpdt4MTpMXclMT3lXbmRY+XRakw==", + "license": "MIT" + }, "node_modules/resolve": { "version": "1.22.12", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", @@ -19335,7 +19468,7 @@ "version": "6.0.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", @@ -20380,6 +20513,15 @@ "node": ">=18" } }, + "node_modules/void-elements": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz", + "integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/w3c-keyname": { "version": "2.2.8", "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", diff --git a/packages/relay/src/errors.ts b/packages/relay/src/errors.ts index 59a36a315..00c1fd571 100644 --- a/packages/relay/src/errors.ts +++ b/packages/relay/src/errors.ts @@ -48,7 +48,7 @@ export class AssumptionRequiredError extends Error { constructor(message?: string) { super(message ?? "ASSUMPTION_REQUIRED"); this.name = "AssumptionRequiredError"; - Object.setPrototypeOf(this, AssumptionRequiredError.prototype) + Object.setPrototypeOf(this, AssumptionRequiredError.prototype); } } diff --git a/packages/relay/src/fetch.ts b/packages/relay/src/fetch.ts index 576f54a86..5068689cf 100644 --- a/packages/relay/src/fetch.ts +++ b/packages/relay/src/fetch.ts @@ -12,117 +12,120 @@ // OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR // PERFORMANCE OF THIS SOFTWARE. -import { type FetchFunction } from "relay-runtime"; -import { - InternalServerError, - UnAuthenticatedError, - ForbiddenError, - AssumptionRequiredError, - NDASignatureRequiredError, - FullNameRequiredError, -} from "./errors"; import { GraphQLError } from "graphql"; +import { type FetchFunction, type GraphQLResponse } from "relay-runtime"; + +import { + AssumptionRequiredError, + ForbiddenError, + FullNameRequiredError, + InternalServerError, + NDASignatureRequiredError, + UnAuthenticatedError, +} from "./errors"; const hasUnauthenticatedError = (error: GraphQLError) => - error.extensions?.code === "UNAUTHENTICATED"; + error.extensions?.code === "UNAUTHENTICATED"; const hasFullNameRequiredError = (error: GraphQLError) => - error.extensions?.code === "FULL_NAME_REQUIRED"; + error.extensions?.code === "FULL_NAME_REQUIRED"; const hasAssumptionRequiredError = (error: GraphQLError) => - error.extensions?.code === "ASSUMPTION_REQUIRED"; + error.extensions?.code === "ASSUMPTION_REQUIRED"; const hasNDASignatureRequiredError = (error: GraphQLError) => - error.extensions?.code === "NDA_SIGNATURE_REQUIRED"; + error.extensions?.code === "NDA_SIGNATURE_REQUIRED"; const hasForbiddenError = (error: GraphQLError) => - error.extensions?.code === "FORBIDDEN"; + error.extensions?.code === "FORBIDDEN"; export const makeFetchQuery = (endpoint: string): FetchFunction => { - return async (request, variables, _, uploadables) => { - const requestInit: RequestInit = { - method: "POST", - credentials: "include", - headers: {}, - }; - - if (uploadables) { - const formData = new FormData(); - formData.append( - "operations", - JSON.stringify({ - operationName: request.name, - query: request.text, - variables: variables, - }), - ); - - const uploadableMap: { - [key: string]: string[]; - } = {}; - const uploadableKeys = Object.keys(uploadables); - - uploadableKeys.forEach((key) => { - uploadableMap[key] = [`variables.${key}`]; - }); - - formData.append("map", JSON.stringify(uploadableMap)); - - uploadableKeys.forEach((key) => { - formData.append(key, uploadables[key]); - }); - - requestInit.body = formData; - } else { - requestInit.headers = { - Accept: "application/graphql-response+json; charset=utf-8, application/json; charset=utf-8", - "Content-Type": "application/json", - }; - - requestInit.body = JSON.stringify({ - operationName: request.name, - query: request.text, - variables, - }); - } - - const response = await fetch(endpoint, requestInit); - - if (response.status === 500) { - throw new InternalServerError(); - } - - const json = await response.json(); - - if (json.errors) { - const errors = json.errors as GraphQLError[]; - - const unauthenticatedError = errors.find(hasUnauthenticatedError); - if (unauthenticatedError) { - throw new UnAuthenticatedError(unauthenticatedError.message); - } - - const fullNameRequiredError = errors.find(hasFullNameRequiredError); - if (fullNameRequiredError) { - throw new FullNameRequiredError(fullNameRequiredError.message); - } - - const assumptionRequiredError = errors.find(hasAssumptionRequiredError); - if (assumptionRequiredError) { - throw new AssumptionRequiredError(assumptionRequiredError.message); - } - - const ndaSignatureRequiredError = errors.find(hasNDASignatureRequiredError); - if (ndaSignatureRequiredError) { - throw new NDASignatureRequiredError(ndaSignatureRequiredError.message); - } - - const forbiddenError = errors.find(hasForbiddenError); - if (forbiddenError) { - throw new ForbiddenError(forbiddenError.message); - } - } - - return json; + return async (request, variables, _, uploadables) => { + const requestInit: RequestInit = { + method: "POST", + credentials: "include", + headers: {}, }; + + if (uploadables) { + const formData = new FormData(); + formData.append( + "operations", + JSON.stringify({ + operationName: request.name, + query: request.text, + variables: variables, + }), + ); + + const uploadableMap: { + [key: string]: string[]; + } = {}; + const uploadableKeys = Object.keys(uploadables); + + uploadableKeys.forEach((key) => { + uploadableMap[key] = [`variables.${key}`]; + }); + + formData.append("map", JSON.stringify(uploadableMap)); + + uploadableKeys.forEach((key) => { + formData.append(key, uploadables[key]); + }); + + requestInit.body = formData; + } else { + requestInit.headers = { + "Accept": "application/graphql-response+json; charset=utf-8, application/json; charset=utf-8", + "Content-Type": "application/json", + }; + + requestInit.body = JSON.stringify({ + operationName: request.name, + query: request.text, + variables, + }); + } + + const response = await fetch(endpoint, requestInit); + + if (response.status === 500) { + throw new InternalServerError(); + } + + const json = (await response.json()) as GraphQLResponse & { + errors?: GraphQLError[]; + }; + + if (json.errors) { + const errors = json.errors; + + const unauthenticatedError = errors.find(hasUnauthenticatedError); + if (unauthenticatedError) { + throw new UnAuthenticatedError(unauthenticatedError.message); + } + + const fullNameRequiredError = errors.find(hasFullNameRequiredError); + if (fullNameRequiredError) { + throw new FullNameRequiredError(fullNameRequiredError.message); + } + + const assumptionRequiredError = errors.find(hasAssumptionRequiredError); + if (assumptionRequiredError) { + throw new AssumptionRequiredError(assumptionRequiredError.message); + } + + const ndaSignatureRequiredError = errors.find(hasNDASignatureRequiredError); + if (ndaSignatureRequiredError) { + throw new NDASignatureRequiredError(ndaSignatureRequiredError.message); + } + + const forbiddenError = errors.find(hasForbiddenError); + if (forbiddenError) { + throw new ForbiddenError(forbiddenError.message); + } + } + + return json; + }; }; diff --git a/packages/relay/src/index.ts b/packages/relay/src/index.ts index a3b65ed82..c6f7d9c7e 100644 --- a/packages/relay/src/index.ts +++ b/packages/relay/src/index.ts @@ -14,3 +14,8 @@ export { makeFetchQuery } from "./fetch"; export * from "./errors"; +export { + createUseMutation, + type MutationFeedback, + type MutationNotifier, +} from "./useMutation"; diff --git a/packages/relay/src/useMutation.ts b/packages/relay/src/useMutation.ts new file mode 100644 index 000000000..6a116d834 --- /dev/null +++ b/packages/relay/src/useMutation.ts @@ -0,0 +1,117 @@ +// Copyright (c) 2025-2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +import { useCallback } from "react"; +import { useMutation as useRelayMutation, type UseMutationConfig } from "react-relay"; +import type { + GraphQLTaggedNode, + MutationParameters, + PayloadError, +} from "relay-runtime"; + +/** + * App-supplied surface for rendering mutation feedback. The shared hook owns + * *when* to notify; the host app owns *how* (toast system, i18n, error + * formatting), keeping this package free of UI and i18n dependencies. + * + * `notifyError` receives an optional title override; when omitted, the + * implementation supplies its own (localized) default. + */ +export type MutationNotifier = { + notifySuccess: (message: string) => void; + notifyError: (error: Error | PayloadError, title?: string) => void; +}; + +export type MutationFeedback = { + // Message shown on success. Omit for no success notification. + successMessage?: string; + // Error notification behavior: `true` (default) notifies with the notifier's + // default title, a string overrides that title, and `false` disables the + // automatic notification so the caller handles the rejected promise itself. + errorToast?: boolean | string; +}; + +/** + * Builds an awaitable `useMutation` hook bound to a host-provided notifier. + * + * The returned hook wraps react-relay's `useMutation` so that callers can + * `await` and continue only on success: + * + * - resolves with the mutation response on success; + * - preserves every UseMutationConfig option by spreading the caller's config; + * - on failure, notifies via the injected notifier (unless disabled) AND + * rejects. + * + * Each app calls this once with its own notifier hook and re-exports the + * result as the canonical `useMutation`. + */ +export function createUseMutation(useNotifier: () => MutationNotifier) { + return function useMutation( + mutation: GraphQLTaggedNode, + feedback?: MutationFeedback, + ) { + const [commit, isInFlight] = useRelayMutation(mutation); + const notifier = useNotifier(); + + const { successMessage: baseSuccess, errorToast: baseErrorToast = true } = feedback ?? {}; + + const mutate = useCallback( + (config: UseMutationConfig, overrides?: MutationFeedback): Promise => { + const successMessage = overrides?.successMessage ?? baseSuccess; + const errorToast = overrides?.errorToast ?? baseErrorToast; + + function notifyError(error: Error | PayloadError) { + if (errorToast === false) { + return; + } + notifier.notifyError( + error, + typeof errorToast === "string" ? errorToast : undefined, + ); + } + + return new Promise((resolve, reject) => { + commit({ + ...config, + onCompleted: (response, errors) => { + config.onCompleted?.(response, errors); + if (errors && errors.length > 0) { + const [payloadError] = errors; + notifyError(payloadError); + reject( + payloadError instanceof Error + ? payloadError + : new Error(payloadError.message), + ); + return; + } + if (successMessage) { + notifier.notifySuccess(successMessage); + } + resolve(response); + }, + onError: (error) => { + config.onError?.(error); + notifyError(error); + reject(error); + }, + }); + }); + }, + [commit, notifier, baseSuccess, baseErrorToast], + ); + + return [mutate, isInFlight] as const; + }; +} diff --git a/packages/routes/src/appRoute.tsx b/packages/routes/src/appRoute.tsx index f20a00cf5..0c09e68c2 100644 --- a/packages/routes/src/appRoute.tsx +++ b/packages/routes/src/appRoute.tsx @@ -18,7 +18,7 @@ import { type RouteObject } from "react-router"; export type AppRoute = Omit & { children?: AppRoute[]; Fallback?: ComponentType; -} +}; export function routeFromAppRoute(appRoute: AppRoute): RouteObject { const { Component, Fallback, children, ...rest } = appRoute; diff --git a/packages/routes/src/index.ts b/packages/routes/src/index.ts index 3b9e23f84..501cc901a 100644 --- a/packages/routes/src/index.ts +++ b/packages/routes/src/index.ts @@ -12,5 +12,5 @@ // OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR // PERFORMANCE OF THIS SOFTWARE. -export { routeFromAppRoute, type AppRoute } from "./appRoute"; -export { withQueryRef, loaderFromQueryLoader } from "./relay"; +export { type AppRoute, routeFromAppRoute } from "./appRoute"; +export { loaderFromQueryLoader, withQueryRef } from "./relay"; diff --git a/packages/routes/src/relay.tsx b/packages/routes/src/relay.tsx index 3285a941a..c9bdbe39a 100644 --- a/packages/routes/src/relay.tsx +++ b/packages/routes/src/relay.tsx @@ -12,21 +12,31 @@ // OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR // PERFORMANCE OF THIS SOFTWARE. +import { useCleanup } from "@probo/hooks"; import { type ComponentType } from "react"; import type { EnvironmentProviderOptions, PreloadedQuery } from "react-relay"; import { type LoaderFunction, type LoaderFunctionArgs, useLoaderData } from "react-router"; import { type OperationType } from "relay-runtime"; -import { useCleanup } from "@probo/hooks"; -// Infer the concrete `queryRef` type from a naked type position. Relay 21's -// first-party types model `PreloadedQuery#variables` as `VariablesOf`, -// which prevents inferring `TQuery` through it, so we infer the whole queryRef. +/** + * @deprecated Use a `*PageLoader` component with `useQueryLoader` + + * `usePreloadedQuery` instead. See contrib/claude/relay.md. + * + * Infer the concrete `queryRef` type from a naked type position. Relay 21's + * first-party types model `PreloadedQuery#variables` as `VariablesOf`, + * which prevents inferring `TQuery` through it, so we infer the whole queryRef. + */ export function withQueryRef< - TQueryRef extends PreloadedQuery + TQueryRef extends PreloadedQuery, >( Component: ComponentType<{ queryRef: TQueryRef }>, ) { - return () => { + return function WithQueryRef() { + // `useLoaderData` is typed `any` (default generic), and its `SerializeFrom` + // generic would strip the `dispose` function type. Assert the loader's + // shape so the rest of the component stays type-safe; the assertion is not + // redundant despite the rule flagging it (the source is `any`). + // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion const { queryRef, dispose } = useLoaderData() as { queryRef: TQueryRef; dispose: () => void; @@ -34,15 +44,19 @@ export function withQueryRef< useCleanup(dispose, 1000); - return - } + return ; + }; } +/** + * @deprecated Use a `*PageLoader` component with `useQueryLoader` + + * `usePreloadedQuery` instead. See contrib/claude/relay.md. + */ export function loaderFromQueryLoader< TQuery extends OperationType, - TEnvironmentProviderOptions = EnvironmentProviderOptions + TEnvironmentProviderOptions = EnvironmentProviderOptions, >( - queryLoader: (params: Record) => PreloadedQuery + queryLoader: (params: Record) => PreloadedQuery, ): LoaderFunction { return ({ params }: LoaderFunctionArgs) => { const query = queryLoader(params as Record); @@ -50,5 +64,5 @@ export function loaderFromQueryLoader< queryRef: query, dispose: query.dispose, }; - } + }; }