Promote useMutation to the @probo/relay package
Extract the awaitable useMutation into @probo/relay as a createUseMutation factory that delegates feedback to an injected MutationNotifier, keeping the package free of UI and i18n dependencies. compliance-portal binds it to its Base UI toast + i18next + formatError stack and imports it by explicit path (#/lib/relay/useMutation), dropping the lone intra-app barrel; a compliance-portal-scoped no-restricted-imports rule forbids react-relay's useMutation. Bring packages/relay and packages/routes into the shared ESLint scope and fix the violations that surfaced, and deprecate the legacy withQueryRef / loaderFromQueryLoader helpers. Document the shared-hook pattern and the "index.ts for package entrypoints only" rule in the relay, hooks, and app-arborescence guides. Signed-off-by: Émile Ré <emile@probo.com>
This commit is contained in:
@@ -10,12 +10,16 @@
|
|||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@base-ui/react": "^1.6.0",
|
||||||
|
"@probo/helpers": "1.0.0",
|
||||||
"@probo/react-lazy": "1.0.0",
|
"@probo/react-lazy": "1.0.0",
|
||||||
|
"@probo/relay": "1.0.0",
|
||||||
"@probo/routes": "1.0.0",
|
"@probo/routes": "1.0.0",
|
||||||
"@probo/ui": "1.0.0",
|
"@probo/ui": "1.0.0",
|
||||||
"clsx": "^2.1.1",
|
"i18next": "^26.3.1",
|
||||||
"react": "^19.2.7",
|
"react": "^19.2.7",
|
||||||
"react-dom": "^19.2.7",
|
"react-dom": "^19.2.7",
|
||||||
|
"react-i18next": "^17.0.8",
|
||||||
"react-relay": "^21.0.1",
|
"react-relay": "^21.0.1",
|
||||||
"react-router": "^8.0.0",
|
"react-router": "^8.0.0",
|
||||||
"relay-runtime": "^21.0.1"
|
"relay-runtime": "^21.0.1"
|
||||||
|
|||||||
53
apps/compliance-portal/src/lib/relay/useMutation.ts
Normal file
53
apps/compliance-portal/src/lib/relay/useMutation.ts
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
||||||
|
//
|
||||||
|
// 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<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 type { MutationFeedback } from "@probo/relay";
|
||||||
|
|
||||||
|
export const useMutation = createUseMutation(useMutationNotifier);
|
||||||
@@ -344,7 +344,7 @@ pages/organizations/third-parties/_components/ThirdPartyContactListItem.tsx
|
|||||||
|
|
||||||
## `_lib` folder
|
## `_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
|
```text
|
||||||
// Good — feature-scoped helpers under _lib
|
// Good — feature-scoped helpers under _lib
|
||||||
@@ -356,6 +356,25 @@ pages/organizations/third-parties/
|
|||||||
ThirdPartyListItem.tsx
|
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
|
## `_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.
|
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.
|
||||||
|
|||||||
158
contrib/claude/hooks.md
Normal file
158
contrib/claude/hooks.md
Normal file
@@ -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<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 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>(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.
|
||||||
@@ -288,7 +288,34 @@ Every mutation **must** update the Relay store so the UI reflects changes immedi
|
|||||||
|
|
||||||
### `useMutation`
|
### `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<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
|
#### Naming convention
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { defineConfig, globalIgnores } from "eslint/config";
|
|||||||
// sets below; everything else is ignored so a bare `eslint .` keeps the same
|
// sets below; everything else is ignored so a bare `eslint .` keeps the same
|
||||||
// scope as the previous per-workspace configs.
|
// scope as the previous per-workspace configs.
|
||||||
const appDirs = ["apps/console/**", "apps/trust/**", "apps/compliance-portal/**"];
|
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/**"];
|
const lintedDirs = [...reactDirs, "packages/eslint-config/**"];
|
||||||
|
|
||||||
export default defineConfig([
|
export default defineConfig([
|
||||||
@@ -25,8 +25,6 @@ export default defineConfig([
|
|||||||
"packages/n8n-node/**",
|
"packages/n8n-node/**",
|
||||||
"packages/prosemirror/**",
|
"packages/prosemirror/**",
|
||||||
"packages/react-lazy/**",
|
"packages/react-lazy/**",
|
||||||
"packages/relay/**",
|
|
||||||
"packages/routes/**",
|
|
||||||
"packages/tsconfig/**",
|
"packages/tsconfig/**",
|
||||||
]),
|
]),
|
||||||
{
|
{
|
||||||
@@ -48,6 +46,28 @@ export default defineConfig([
|
|||||||
files: appDirs,
|
files: appDirs,
|
||||||
extends: [configs.relay],
|
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,
|
files: reactDirs,
|
||||||
ignores: ["packages/ui/tailwind.config.js"],
|
ignores: ["packages/ui/tailwind.config.js"],
|
||||||
|
|||||||
146
package-lock.json
generated
146
package-lock.json
generated
@@ -29,12 +29,15 @@
|
|||||||
"name": "@probo/compliance-portal",
|
"name": "@probo/compliance-portal",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@base-ui/react": "^1.6.0",
|
||||||
|
"@probo/helpers": "1.0.0",
|
||||||
"@probo/react-lazy": "1.0.0",
|
"@probo/react-lazy": "1.0.0",
|
||||||
"@probo/routes": "1.0.0",
|
"@probo/routes": "1.0.0",
|
||||||
"@probo/ui": "1.0.0",
|
"@probo/ui": "1.0.0",
|
||||||
"clsx": "^2.1.1",
|
"i18next": "^26.3.1",
|
||||||
"react": "^19.2.7",
|
"react": "^19.2.7",
|
||||||
"react-dom": "^19.2.7",
|
"react-dom": "^19.2.7",
|
||||||
|
"react-i18next": "^17.0.8",
|
||||||
"react-relay": "^21.0.1",
|
"react-relay": "^21.0.1",
|
||||||
"react-router": "^8.0.0",
|
"react-router": "^8.0.0",
|
||||||
"relay-runtime": "^21.0.1"
|
"relay-runtime": "^21.0.1"
|
||||||
@@ -570,6 +573,66 @@
|
|||||||
"node": ">=6.9.0"
|
"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": {
|
"node_modules/@bcoe/v8-coverage": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz",
|
||||||
@@ -12575,6 +12638,15 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/html-to-text": {
|
||||||
"version": "9.0.5",
|
"version": "9.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/html-to-text/-/html-to-text-9.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/html-to-text/-/html-to-text-9.0.5.tgz",
|
||||||
@@ -12655,6 +12727,34 @@
|
|||||||
"ms": "^2.0.0"
|
"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": {
|
"node_modules/ibm-cloud-sdk-core": {
|
||||||
"version": "5.5.0",
|
"version": "5.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/ibm-cloud-sdk-core/-/ibm-cloud-sdk-core-5.5.0.tgz",
|
"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"
|
"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": {
|
"node_modules/react-intersection-observer": {
|
||||||
"version": "9.16.0",
|
"version": "9.16.0",
|
||||||
"resolved": "https://registry.npmjs.org/react-intersection-observer/-/react-intersection-observer-9.16.0.tgz",
|
"resolved": "https://registry.npmjs.org/react-intersection-observer/-/react-intersection-observer-9.16.0.tgz",
|
||||||
@@ -17793,6 +17920,12 @@
|
|||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true
|
"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": {
|
"node_modules/resolve": {
|
||||||
"version": "1.22.12",
|
"version": "1.22.12",
|
||||||
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz",
|
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz",
|
||||||
@@ -19335,7 +19468,7 @@
|
|||||||
"version": "6.0.3",
|
"version": "6.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz",
|
||||||
"integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
|
"integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
|
||||||
"dev": true,
|
"devOptional": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"bin": {
|
"bin": {
|
||||||
"tsc": "bin/tsc",
|
"tsc": "bin/tsc",
|
||||||
@@ -20380,6 +20513,15 @@
|
|||||||
"node": ">=18"
|
"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": {
|
"node_modules/w3c-keyname": {
|
||||||
"version": "2.2.8",
|
"version": "2.2.8",
|
||||||
"resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
|
"resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ export class AssumptionRequiredError extends Error {
|
|||||||
constructor(message?: string) {
|
constructor(message?: string) {
|
||||||
super(message ?? "ASSUMPTION_REQUIRED");
|
super(message ?? "ASSUMPTION_REQUIRED");
|
||||||
this.name = "AssumptionRequiredError";
|
this.name = "AssumptionRequiredError";
|
||||||
Object.setPrototypeOf(this, AssumptionRequiredError.prototype)
|
Object.setPrototypeOf(this, AssumptionRequiredError.prototype);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,16 +12,17 @@
|
|||||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||||
// PERFORMANCE OF THIS SOFTWARE.
|
// PERFORMANCE OF THIS SOFTWARE.
|
||||||
|
|
||||||
import { type FetchFunction } from "relay-runtime";
|
|
||||||
import {
|
|
||||||
InternalServerError,
|
|
||||||
UnAuthenticatedError,
|
|
||||||
ForbiddenError,
|
|
||||||
AssumptionRequiredError,
|
|
||||||
NDASignatureRequiredError,
|
|
||||||
FullNameRequiredError,
|
|
||||||
} from "./errors";
|
|
||||||
import { GraphQLError } from "graphql";
|
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) =>
|
const hasUnauthenticatedError = (error: GraphQLError) =>
|
||||||
error.extensions?.code === "UNAUTHENTICATED";
|
error.extensions?.code === "UNAUTHENTICATED";
|
||||||
@@ -75,7 +76,7 @@ export const makeFetchQuery = (endpoint: string): FetchFunction => {
|
|||||||
requestInit.body = formData;
|
requestInit.body = formData;
|
||||||
} else {
|
} else {
|
||||||
requestInit.headers = {
|
requestInit.headers = {
|
||||||
Accept: "application/graphql-response+json; charset=utf-8, application/json; charset=utf-8",
|
"Accept": "application/graphql-response+json; charset=utf-8, application/json; charset=utf-8",
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -92,10 +93,12 @@ export const makeFetchQuery = (endpoint: string): FetchFunction => {
|
|||||||
throw new InternalServerError();
|
throw new InternalServerError();
|
||||||
}
|
}
|
||||||
|
|
||||||
const json = await response.json();
|
const json = (await response.json()) as GraphQLResponse & {
|
||||||
|
errors?: GraphQLError[];
|
||||||
|
};
|
||||||
|
|
||||||
if (json.errors) {
|
if (json.errors) {
|
||||||
const errors = json.errors as GraphQLError[];
|
const errors = json.errors;
|
||||||
|
|
||||||
const unauthenticatedError = errors.find(hasUnauthenticatedError);
|
const unauthenticatedError = errors.find(hasUnauthenticatedError);
|
||||||
if (unauthenticatedError) {
|
if (unauthenticatedError) {
|
||||||
|
|||||||
@@ -14,3 +14,8 @@
|
|||||||
|
|
||||||
export { makeFetchQuery } from "./fetch";
|
export { makeFetchQuery } from "./fetch";
|
||||||
export * from "./errors";
|
export * from "./errors";
|
||||||
|
export {
|
||||||
|
createUseMutation,
|
||||||
|
type MutationFeedback,
|
||||||
|
type MutationNotifier,
|
||||||
|
} from "./useMutation";
|
||||||
|
|||||||
117
packages/relay/src/useMutation.ts
Normal file
117
packages/relay/src/useMutation.ts
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
// Copyright (c) 2025-2026 Probo Inc <hello@probo.com>.
|
||||||
|
//
|
||||||
|
// 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<T extends MutationParameters>(
|
||||||
|
mutation: GraphQLTaggedNode,
|
||||||
|
feedback?: MutationFeedback,
|
||||||
|
) {
|
||||||
|
const [commit, isInFlight] = useRelayMutation<T>(mutation);
|
||||||
|
const notifier = useNotifier();
|
||||||
|
|
||||||
|
const { successMessage: baseSuccess, errorToast: baseErrorToast = true } = feedback ?? {};
|
||||||
|
|
||||||
|
const mutate = useCallback(
|
||||||
|
(config: UseMutationConfig<T>, overrides?: MutationFeedback): Promise<T["response"]> => {
|
||||||
|
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<T["response"]>((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;
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -18,7 +18,7 @@ import { type RouteObject } from "react-router";
|
|||||||
export type AppRoute = Omit<RouteObject, "children"> & {
|
export type AppRoute = Omit<RouteObject, "children"> & {
|
||||||
children?: AppRoute[];
|
children?: AppRoute[];
|
||||||
Fallback?: ComponentType;
|
Fallback?: ComponentType;
|
||||||
}
|
};
|
||||||
|
|
||||||
export function routeFromAppRoute(appRoute: AppRoute): RouteObject {
|
export function routeFromAppRoute(appRoute: AppRoute): RouteObject {
|
||||||
const { Component, Fallback, children, ...rest } = appRoute;
|
const { Component, Fallback, children, ...rest } = appRoute;
|
||||||
|
|||||||
@@ -12,5 +12,5 @@
|
|||||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||||
// PERFORMANCE OF THIS SOFTWARE.
|
// PERFORMANCE OF THIS SOFTWARE.
|
||||||
|
|
||||||
export { routeFromAppRoute, type AppRoute } from "./appRoute";
|
export { type AppRoute, routeFromAppRoute } from "./appRoute";
|
||||||
export { withQueryRef, loaderFromQueryLoader } from "./relay";
|
export { loaderFromQueryLoader, withQueryRef } from "./relay";
|
||||||
|
|||||||
@@ -12,21 +12,31 @@
|
|||||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||||
// PERFORMANCE OF THIS SOFTWARE.
|
// PERFORMANCE OF THIS SOFTWARE.
|
||||||
|
|
||||||
|
import { useCleanup } from "@probo/hooks";
|
||||||
import { type ComponentType } from "react";
|
import { type ComponentType } from "react";
|
||||||
import type { EnvironmentProviderOptions, PreloadedQuery } from "react-relay";
|
import type { EnvironmentProviderOptions, PreloadedQuery } from "react-relay";
|
||||||
import { type LoaderFunction, type LoaderFunctionArgs, useLoaderData } from "react-router";
|
import { type LoaderFunction, type LoaderFunctionArgs, useLoaderData } from "react-router";
|
||||||
import { type OperationType } from "relay-runtime";
|
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<TQuery>`,
|
* @deprecated Use a `*PageLoader` component with `useQueryLoader` +
|
||||||
// which prevents inferring `TQuery` through it, so we infer the whole queryRef.
|
* `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<TQuery>`,
|
||||||
|
* which prevents inferring `TQuery` through it, so we infer the whole queryRef.
|
||||||
|
*/
|
||||||
export function withQueryRef<
|
export function withQueryRef<
|
||||||
TQueryRef extends PreloadedQuery<OperationType>
|
TQueryRef extends PreloadedQuery<OperationType>,
|
||||||
>(
|
>(
|
||||||
Component: ComponentType<{ queryRef: TQueryRef }>,
|
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 {
|
const { queryRef, dispose } = useLoaderData() as {
|
||||||
queryRef: TQueryRef;
|
queryRef: TQueryRef;
|
||||||
dispose: () => void;
|
dispose: () => void;
|
||||||
@@ -34,15 +44,19 @@ export function withQueryRef<
|
|||||||
|
|
||||||
useCleanup(dispose, 1000);
|
useCleanup(dispose, 1000);
|
||||||
|
|
||||||
return <Component queryRef={queryRef} />
|
return <Component queryRef={queryRef} />;
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @deprecated Use a `*PageLoader` component with `useQueryLoader` +
|
||||||
|
* `usePreloadedQuery` instead. See contrib/claude/relay.md.
|
||||||
|
*/
|
||||||
export function loaderFromQueryLoader<
|
export function loaderFromQueryLoader<
|
||||||
TQuery extends OperationType,
|
TQuery extends OperationType,
|
||||||
TEnvironmentProviderOptions = EnvironmentProviderOptions
|
TEnvironmentProviderOptions = EnvironmentProviderOptions,
|
||||||
>(
|
>(
|
||||||
queryLoader: (params: Record<string, string>) => PreloadedQuery<TQuery, TEnvironmentProviderOptions>
|
queryLoader: (params: Record<string, string>) => PreloadedQuery<TQuery, TEnvironmentProviderOptions>,
|
||||||
): LoaderFunction {
|
): LoaderFunction {
|
||||||
return ({ params }: LoaderFunctionArgs) => {
|
return ({ params }: LoaderFunctionArgs) => {
|
||||||
const query = queryLoader(params as Record<string, string>);
|
const query = queryLoader(params as Record<string, string>);
|
||||||
@@ -50,5 +64,5 @@ export function loaderFromQueryLoader<
|
|||||||
queryRef: query,
|
queryRef: query,
|
||||||
dispose: query.dispose,
|
dispose: query.dispose,
|
||||||
};
|
};
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user