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:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -14,3 +14,8 @@
|
||||
|
||||
export { makeFetchQuery } from "./fetch";
|
||||
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;
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user