303 lines
10 KiB
TypeScript
303 lines
10 KiB
TypeScript
import React, { useEffect } from "react";
|
|
import { browserHistory } from "react-router";
|
|
import { Toaster, toast, ExternalToast } from "sonner";
|
|
|
|
import ToastCard, { ToastVariant } from "./ToastCard";
|
|
|
|
const baseClass = "toast-notification";
|
|
|
|
// Auto close duration (error toast is never closed automatically)
|
|
const SUCCESS_DURATION = 5000;
|
|
const ERROR_DURATION = Infinity;
|
|
|
|
// Fallback copy for error toasts called with an empty message. Error helpers
|
|
// (getErrorReason/getErrorMessage) return "" for errors they can't parse — e.g.
|
|
// a network error — so without this the toast would render with no message,
|
|
// just the raw-response panel. Keeps every error toast meaningful.
|
|
const GENERIC_ERROR_MESSAGE = "Something went wrong. Please try again.";
|
|
|
|
// Max number of visible toasts at the same time.
|
|
const VISIBLE_TOASTS = 10;
|
|
|
|
export interface IToastNotificationProps {
|
|
className?: string;
|
|
}
|
|
|
|
const ToastNotification = ({
|
|
className,
|
|
}: IToastNotificationProps): JSX.Element => {
|
|
const classes = className
|
|
? `${baseClass} ${baseClass}__wrapper ${className}`
|
|
: `${baseClass} ${baseClass}__wrapper`;
|
|
|
|
// Dismiss visible toasts on route change, matching 4.86 flash behavior.
|
|
// Only fires when the pathname actually changes — query-param `replace`s
|
|
// (e.g. TableContainer's onQueryChange URL sync on initial render) are
|
|
// ignored so they don't kill a toast that just landed on the destination
|
|
// page (#48180). The listener fires synchronously during router.push;
|
|
// because `notify` defers creation by a tick (see below), a toast
|
|
// triggered alongside a navigation is created afterward and lands on the
|
|
// destination page.
|
|
useEffect(() => {
|
|
let prevPathname = window.location.pathname;
|
|
const unlisten = browserHistory.listen((location) => {
|
|
if (location.pathname !== prevPathname) {
|
|
prevPathname = location.pathname;
|
|
toast.dismiss();
|
|
}
|
|
});
|
|
return unlisten;
|
|
}, []);
|
|
|
|
return (
|
|
<Toaster
|
|
className={classes}
|
|
position="bottom-right"
|
|
visibleToasts={VISIBLE_TOASTS}
|
|
/>
|
|
);
|
|
};
|
|
|
|
/**
|
|
* Minimal shape of an HTTP response as surfaced by Fleet's API wrapper
|
|
* (`frontend/services/index.ts`). Used by `notify.error(..., { response })`
|
|
* to auto-derive the expandable panel's label and body.
|
|
*/
|
|
export interface INotifyResponse {
|
|
status?: number;
|
|
statusText?: string;
|
|
data?: unknown;
|
|
}
|
|
|
|
/**
|
|
* Options accepted by `notify.*`. This is Fleet's own curated surface — NOT
|
|
* Sonner's. We expose only the keys we explicitly support and forward; callers
|
|
* should program against this interface rather than reaching for Sonner
|
|
* options. To allow a new Sonner passthrough, add the field here AND copy it
|
|
* through in `toSonnerOptions` below.
|
|
*/
|
|
export interface INotifyOptions {
|
|
/**
|
|
* Auto-dismiss after N ms. Defaults: 5000 (success), Infinity (error).
|
|
* Forwarded to Sonner.
|
|
*/
|
|
duration?: number;
|
|
/**
|
|
* Reuse an id to replace/update an existing toast in place instead of
|
|
* stacking a new one (e.g. a "Saving…" toast that becomes "Saved").
|
|
* Forwarded to Sonner.
|
|
*/
|
|
id?: ToastId;
|
|
/**
|
|
* Pass the API response object (or the value a rejected promise
|
|
* yielded from Fleet's `sendRequest` — typically the axios response,
|
|
* which already has the `INotifyResponse` shape). The toast
|
|
* auto-populates the expandable panel with `response.data` as the body
|
|
* and `"Status: {status} {statusText}"` as the label above it.
|
|
*
|
|
* Typed `unknown` so caught errors can be passed directly from
|
|
* `catch (e)` blocks; values without a response shape render as the raw
|
|
* payload with the default label.
|
|
*
|
|
* For non-HTTP payloads, construct a minimal response-shaped object:
|
|
* `{ response: { data: anyObject } }`.
|
|
*/
|
|
response?: unknown;
|
|
/**
|
|
* Override the label shown above the payload. Defaults to the
|
|
* auto-derived `"Status: {status} {statusText}"` when `response` is
|
|
* provided, otherwise `"Raw response"`.
|
|
*/
|
|
detailLabel?: string;
|
|
}
|
|
|
|
export type ToastId = string | number;
|
|
|
|
/**
|
|
* One toast in a `notify.batch` call. Mirrors the single-toast API:
|
|
* `variant` picks `notify.success`/`notify.error` behavior and `options`
|
|
* supports the same fields, including `response` for the expandable
|
|
* raw-response panel on error toasts.
|
|
*/
|
|
export interface INotifyBatchItem {
|
|
variant: ToastVariant;
|
|
message: React.ReactNode;
|
|
options?: INotifyOptions;
|
|
}
|
|
|
|
/**
|
|
* Imperative API for triggering toasts. Intended to be called from anywhere
|
|
* in the app — handlers, effects, services — once `<ToastNotification />`
|
|
* is mounted at the root.
|
|
*/
|
|
export interface INotify {
|
|
success: (message: React.ReactNode, options?: INotifyOptions) => ToastId;
|
|
error: (message: React.ReactNode, options?: INotifyOptions) => ToastId;
|
|
/**
|
|
* Render several toasts at once — e.g. one error per failed item in a
|
|
* bulk operation. Items render in array order; the Toaster caps how many
|
|
* are visible at a time (`VISIBLE_TOASTS`), stacking the rest.
|
|
* Returns the ids in the same order as the input for selective `dismiss`.
|
|
*/
|
|
batch: (items: INotifyBatchItem[]) => ToastId[];
|
|
dismiss: (id?: ToastId) => void;
|
|
}
|
|
|
|
/**
|
|
* Build the Sonner options object from Fleet's curated `INotifyOptions`,
|
|
* copying through ONLY the keys we choose to forward (the rest of
|
|
* `INotifyOptions` — `response`, `detailLabel` — is consumed by us and never
|
|
* reaches Sonner). The caller's `duration` wins over the per-variant default.
|
|
* To forward a new Sonner option, add it to `INotifyOptions` and copy it here.
|
|
*/
|
|
const toSonnerOptions = (
|
|
variantDuration: number,
|
|
options?: INotifyOptions
|
|
): ExternalToast => ({
|
|
duration: options?.duration ?? variantDuration,
|
|
...(options?.id !== undefined && { id: options.id }),
|
|
});
|
|
|
|
/**
|
|
* Friendly names for HTTP status codes most commonly returned by Fleet's
|
|
* API, used as a fallback when axios doesn't provide `statusText` (e.g.
|
|
* under HTTP/2 where browsers leave it empty).
|
|
*/
|
|
const HTTP_STATUS_MEANINGS: Record<number, string> = {
|
|
400: "Bad Request",
|
|
401: "Unauthorized",
|
|
403: "Forbidden",
|
|
404: "Not Found",
|
|
409: "Conflict",
|
|
422: "Unprocessable Entity",
|
|
429: "Too Many Requests",
|
|
500: "Internal Server Error",
|
|
502: "Bad Gateway",
|
|
503: "Service Unavailable",
|
|
504: "Gateway Timeout",
|
|
};
|
|
|
|
/**
|
|
* Derive the body (`detail`) and heading (`detailLabel`) of the expandable
|
|
* panel from the `notify.error` options. `response.data` populates the
|
|
* body; `"Status: {status} {statusText}"` populates the label. An explicit
|
|
* `detailLabel` wins over the auto-derived one.
|
|
*/
|
|
const isObject = (v: unknown): v is Record<string, unknown> =>
|
|
typeof v === "object" && v !== null;
|
|
|
|
// A value carries an HTTP response if it exposes a body or a status code.
|
|
const looksLikeResponse = (v: unknown): boolean =>
|
|
isObject(v) && ("data" in v || "status" in v);
|
|
|
|
const resolveDetailProps = (
|
|
options?: INotifyOptions
|
|
): { detail: unknown; detailLabel: string | undefined } => {
|
|
if (!options || options.response === undefined) {
|
|
return { detail: undefined, detailLabel: options?.detailLabel };
|
|
}
|
|
|
|
// Caught errors are passed as `unknown`. Fleet's `sendRequest` rejects with
|
|
// two shapes: usually the axios *response* (`{ status, data }` at the top
|
|
// level), but for `skipParseError` endpoints (e.g. MDM config) it rejects
|
|
// with the bare AxiosError, whose body lives one level down at
|
|
// `.response.data`. Unwrap that case so the panel reads the real body
|
|
// instead of the AxiosError's (empty) top-level `.data`.
|
|
let resp: unknown = options.response;
|
|
if (
|
|
isObject(resp) &&
|
|
"response" in resp &&
|
|
looksLikeResponse(resp.response)
|
|
) {
|
|
resp = resp.response;
|
|
}
|
|
|
|
if (!looksLikeResponse(resp)) {
|
|
// Non-response payload (e.g. a plain string) — show it raw.
|
|
return { detail: resp, detailLabel: options.detailLabel };
|
|
}
|
|
|
|
const fromResponse = resp as INotifyResponse;
|
|
|
|
// Prefer the server-supplied status text; fall back to a client-side map
|
|
// of common status codes (statusText is often empty over HTTP/2).
|
|
let autoLabel: string | undefined;
|
|
if (fromResponse.status) {
|
|
const meaning =
|
|
fromResponse.statusText || HTTP_STATUS_MEANINGS[fromResponse.status];
|
|
autoLabel = meaning
|
|
? `Status: ${fromResponse.status} ${meaning}`
|
|
: `Status: ${fromResponse.status}`;
|
|
}
|
|
|
|
return {
|
|
detail: fromResponse.data,
|
|
detailLabel: options.detailLabel ?? autoLabel,
|
|
};
|
|
};
|
|
|
|
// Monotonic id source so we can return a toast id synchronously while
|
|
// deferring the actual creation (below). Session-unique is sufficient.
|
|
let toastSeq = 0;
|
|
const nextToastId = (): ToastId => {
|
|
toastSeq += 1;
|
|
return `fleet-toast-${toastSeq}`;
|
|
};
|
|
|
|
export const notify: INotify = {
|
|
success: (message, options) => {
|
|
const id = options?.id ?? nextToastId();
|
|
// Defer one tick so the toast is created after the route-change
|
|
// dismiss above, landing it on the destination page. When a handler
|
|
// both navigates and shows a success toast, call notify.success
|
|
// before router.push — the reverse order can break auto-dismiss (#48088).
|
|
setTimeout(() => {
|
|
toast.custom(
|
|
(sonnerId) => (
|
|
<ToastCard variant="success" message={message} toastId={sonnerId} />
|
|
),
|
|
toSonnerOptions(SUCCESS_DURATION, { ...options, id })
|
|
);
|
|
});
|
|
return id;
|
|
},
|
|
error: (message, options) => {
|
|
const id = options?.id ?? nextToastId();
|
|
// Fall back to generic copy when the caller passes an empty/blank message
|
|
// so the toast is never just an icon + raw-response panel.
|
|
const resolvedMessage =
|
|
message === null ||
|
|
message === undefined ||
|
|
(typeof message === "string" && message.trim() === "")
|
|
? GENERIC_ERROR_MESSAGE
|
|
: message;
|
|
const { detail, detailLabel } = resolveDetailProps(options);
|
|
setTimeout(() => {
|
|
toast.custom(
|
|
(sonnerId) => (
|
|
<ToastCard
|
|
variant="error"
|
|
message={resolvedMessage}
|
|
detail={detail}
|
|
detailLabel={detailLabel}
|
|
toastId={sonnerId}
|
|
/>
|
|
),
|
|
toSonnerOptions(ERROR_DURATION, { ...options, id })
|
|
);
|
|
});
|
|
return id;
|
|
},
|
|
batch: (items) =>
|
|
items.map((item) =>
|
|
item.variant === "success"
|
|
? notify.success(item.message, item.options)
|
|
: notify.error(item.message, item.options)
|
|
),
|
|
dismiss: (id) => {
|
|
toast.dismiss(id);
|
|
},
|
|
};
|
|
|
|
export default ToastNotification;
|