mirror of
https://github.com/webadderallorg/Recordly.git
synced 2026-09-24 14:55:37 +00:00
fix: use native HeroUI toasts with copyable errors
This commit is contained in:
@@ -53,7 +53,6 @@ export function LiveAnnouncementNotifications({ audience }: { audience: Announce
|
||||
recordAnnouncementImpression(announcement.id);
|
||||
const dismiss = () => dismissAnnouncements([announcement.id]);
|
||||
const controls = {
|
||||
close: announcement.controls?.close !== false,
|
||||
action: announcement.controls?.action !== false,
|
||||
};
|
||||
const action = announcement.action;
|
||||
@@ -74,7 +73,6 @@ export function LiveAnnouncementNotifications({ audience }: { audience: Announce
|
||||
duration:
|
||||
(announcement.displayDurationSeconds ??
|
||||
DEFAULT_NOTIFICATION_DURATION_SECONDS) * 1_000,
|
||||
closeButton: controls.close,
|
||||
onDismiss: dismiss,
|
||||
action:
|
||||
action && controls.action
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { afterEach, expect, it, vi } from "vitest";
|
||||
const native = vi.hoisted(() =>
|
||||
Object.assign(
|
||||
vi.fn(() => "toast-key"),
|
||||
{
|
||||
success: vi.fn(),
|
||||
danger: vi.fn(),
|
||||
update: vi.fn(() => "toast-key"),
|
||||
clear: vi.fn(),
|
||||
close: vi.fn(),
|
||||
},
|
||||
),
|
||||
);
|
||||
vi.mock("@heroui/react", () => ({ Toast: { Provider: () => null }, toast: native }));
|
||||
import { toast } from "./toast";
|
||||
afterEach(() => {
|
||||
toast.dismiss();
|
||||
vi.clearAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
it("copies the error title and details through the native action", async () => {
|
||||
const writeText = vi.fn().mockResolvedValue(undefined);
|
||||
vi.stubGlobal("navigator", { clipboard: { writeText } });
|
||||
toast.error("Export failed", { description: "Encoder unavailable" });
|
||||
const options = (
|
||||
native.mock.calls as unknown as [
|
||||
string,
|
||||
{ variant: string; actionProps: { children: string; onPress: () => void } },
|
||||
][]
|
||||
)[0][1];
|
||||
expect(options.variant).toBe("danger");
|
||||
expect(options.actionProps.children).toBe("Copy");
|
||||
options.actionProps.onPress();
|
||||
await vi.waitFor(() => expect(native.success).toHaveBeenCalledWith("Error copied"));
|
||||
expect(writeText).toHaveBeenCalledWith("Export failed\n\nEncoder unavailable");
|
||||
});
|
||||
it("preserves explicit actions and updates named notifications", () => {
|
||||
const onClick = vi.fn();
|
||||
toast.error("Retry", { id: "job", action: { label: "Retry", onClick } });
|
||||
toast.success("Done", { id: "job" });
|
||||
expect(native.update).toHaveBeenCalledWith(
|
||||
"toast-key",
|
||||
"Done",
|
||||
expect.objectContaining({ variant: "success" }),
|
||||
);
|
||||
expect(native).toHaveBeenCalledWith(
|
||||
"Retry",
|
||||
expect.objectContaining({ actionProps: { children: "Retry", onPress: onClick } }),
|
||||
);
|
||||
});
|
||||
+52
-58
@@ -1,43 +1,64 @@
|
||||
import { Toast, ToastQueue } from "@heroui/react";
|
||||
import type { ReactNode } from "react";
|
||||
import { Toast, toast as heroToast } from "@heroui/react";
|
||||
import { isValidElement, type ReactNode } from "react";
|
||||
|
||||
type ToastContent = {
|
||||
title: ReactNode;
|
||||
description?: ReactNode;
|
||||
variant: "default" | "accent" | "success" | "warning" | "danger";
|
||||
action?: { label: ReactNode; onClick: () => void };
|
||||
closeButton?: boolean;
|
||||
};
|
||||
type Options = {
|
||||
id?: string | number;
|
||||
description?: ReactNode;
|
||||
duration?: number;
|
||||
closeButton?: boolean;
|
||||
action?: { label: ReactNode; onClick: () => void };
|
||||
onDismiss?: () => void;
|
||||
};
|
||||
const queue = new ToastQueue<ToastContent>();
|
||||
type Variant = "default" | "accent" | "success" | "warning" | "danger";
|
||||
const ids = new Map<string | number, string>();
|
||||
function notify(
|
||||
title: ReactNode,
|
||||
options: Options = {},
|
||||
variant: ToastContent["variant"] = "default",
|
||||
) {
|
||||
const content = {
|
||||
title,
|
||||
description: options.description,
|
||||
|
||||
function plainText(value: ReactNode): string {
|
||||
if (typeof value === "string" || typeof value === "number") return String(value);
|
||||
if (Array.isArray(value)) return value.map(plainText).join("");
|
||||
if (isValidElement<{ children?: ReactNode }>(value)) return plainText(value.props.children);
|
||||
return "";
|
||||
}
|
||||
|
||||
// Keep existing callers compatible while HeroUI owns the queue and presentation.
|
||||
function notify(title: ReactNode, options: Options = {}, variant: Variant = "default") {
|
||||
const errorText = [plainText(title), plainText(options.description)]
|
||||
.filter(Boolean)
|
||||
.join("\n\n");
|
||||
const action = options.action;
|
||||
const nativeOptions = {
|
||||
variant,
|
||||
action: options.action,
|
||||
closeButton: options.closeButton,
|
||||
};
|
||||
const timeout = options.duration === Infinity ? 0 : (options.duration ?? 4000);
|
||||
const onClose = () => {
|
||||
if (options.id !== undefined) ids.delete(options.id);
|
||||
options.onDismiss?.();
|
||||
description: options.description,
|
||||
timeout:
|
||||
options.duration === Infinity
|
||||
? 0
|
||||
: (options.duration ?? (variant === "danger" ? 8000 : 4000)),
|
||||
onClose: () => {
|
||||
if (options.id !== undefined) ids.delete(options.id);
|
||||
options.onDismiss?.();
|
||||
},
|
||||
actionProps: action
|
||||
? {
|
||||
children: action.label,
|
||||
onPress: action.onClick,
|
||||
}
|
||||
: variant === "danger" && errorText
|
||||
? {
|
||||
children: "Copy",
|
||||
onPress: () => {
|
||||
void navigator.clipboard.writeText(errorText).then(
|
||||
() => heroToast.success("Error copied"),
|
||||
() =>
|
||||
heroToast.danger("Could not copy error", {
|
||||
description: errorText,
|
||||
}),
|
||||
);
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
};
|
||||
const previous = options.id === undefined ? undefined : ids.get(options.id);
|
||||
if (previous && queue.update(previous, content, { timeout, onClose })) return previous;
|
||||
const key = queue.add(content, { timeout, onClose });
|
||||
const key = previous
|
||||
? heroToast.update(previous, title, nativeOptions)
|
||||
: heroToast(title, nativeOptions);
|
||||
if (options.id !== undefined) ids.set(options.id, key);
|
||||
return key;
|
||||
}
|
||||
@@ -48,38 +69,11 @@ export const toast = Object.assign(notify, {
|
||||
warning: (message: ReactNode, options?: Options) => notify(message, options, "warning"),
|
||||
dismiss: (id?: string | number) => {
|
||||
if (id === undefined) {
|
||||
queue.clear();
|
||||
heroToast.clear();
|
||||
ids.clear();
|
||||
} else queue.close(ids.get(id) ?? String(id));
|
||||
} else heroToast.close(ids.get(id) ?? String(id));
|
||||
},
|
||||
});
|
||||
export function Toaster({ className }: { className?: string }) {
|
||||
return (
|
||||
<Toast.Provider queue={queue} placement="bottom end" className={className}>
|
||||
{({ toast: item }) => (
|
||||
<Toast toast={item} variant={item.content.variant}>
|
||||
<Toast.Content>
|
||||
<Toast.Indicator />
|
||||
<div className="min-w-0 flex-1">
|
||||
<Toast.Title>{item.content.title}</Toast.Title>
|
||||
{item.content.description && (
|
||||
<Toast.Description>{item.content.description}</Toast.Description>
|
||||
)}
|
||||
</div>
|
||||
{item.content.action && (
|
||||
<Toast.ActionButton
|
||||
onPress={() => {
|
||||
item.content.action?.onClick();
|
||||
queue.close(item.key);
|
||||
}}
|
||||
>
|
||||
{item.content.action.label}
|
||||
</Toast.ActionButton>
|
||||
)}
|
||||
{item.content.closeButton !== false && <Toast.CloseButton />}
|
||||
</Toast.Content>
|
||||
</Toast>
|
||||
)}
|
||||
</Toast.Provider>
|
||||
);
|
||||
return <Toast.Provider placement="bottom end" className={className} />;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user