Skip to content

Commit 683ec8e

Browse files
feat(ui): add stateful toast utility
Small wrapper around chakra's toast system simplifies creating and updating toasts. See comments in toast.ts for details.
1 parent f31f0cf commit 683ec8e

File tree

2 files changed

+139
-0
lines changed

2 files changed

+139
-0
lines changed
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { Flex, IconButton, Text } from '@invoke-ai/ui-library';
2+
import { t } from 'i18next';
3+
import { PiCopyBold } from 'react-icons/pi';
4+
5+
function onCopy(sessionId: string) {
6+
navigator.clipboard.writeText(sessionId);
7+
}
8+
9+
type Props = { message: string; sessionId: string };
10+
11+
export default function ToastWithSessionRefDescription({ message, sessionId }: Props) {
12+
return (
13+
<Flex flexDir="column">
14+
<Text fontSize="md">{message}</Text>
15+
<Flex gap="2" alignItems="center">
16+
<Text fontSize="sm">{t('toast.sessionRef', { sessionId })}</Text>
17+
<IconButton
18+
size="sm"
19+
aria-label="Copy"
20+
icon={<PiCopyBold />}
21+
onClick={onCopy.bind(null, sessionId)}
22+
variant="ghost"
23+
/>
24+
</Flex>
25+
</Flex>
26+
);
27+
}
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
import type { UseToastOptions } from '@invoke-ai/ui-library';
2+
import { createStandaloneToast, theme, TOAST_OPTIONS } from '@invoke-ai/ui-library';
3+
import { map } from 'nanostores';
4+
import { z } from 'zod';
5+
6+
const toastApi = createStandaloneToast({
7+
theme: theme,
8+
defaultOptions: TOAST_OPTIONS.defaultOptions,
9+
}).toast;
10+
11+
// Slightly modified version of UseToastOptions
12+
type ToastConfig = Omit<UseToastOptions, 'id'> & {
13+
// Only string - Chakra allows numbers
14+
id?: string;
15+
};
16+
17+
type ToastArg = ToastConfig & {
18+
/**
19+
* Whether to append the number of times this toast has been shown to the title. Defaults to true.
20+
* @example
21+
* toast({ title: 'Hello', withCount: true });
22+
* // first toast: 'Hello'
23+
* // second toast: 'Hello (2)'
24+
*/
25+
withCount?: boolean;
26+
};
27+
28+
/* eslint-disable @typescript-eslint/no-explicit-any */
29+
// Any is correct here; we accept anything as toast data parameter.
30+
type ToastInternalState = {
31+
id: string;
32+
config: ToastConfig;
33+
count: number;
34+
};
35+
36+
// We expose a limited API for the toast
37+
type ToastApi = {
38+
getState: () => ToastInternalState | null;
39+
close: () => void;
40+
isActive: () => boolean;
41+
};
42+
43+
// Store each toast state by id, allowing toast consumers to not worry about persistent ids and updating and such
44+
const $toastMap = map<Record<string, ToastInternalState | undefined>>({});
45+
46+
// Helpers to get the getters for the toast API
47+
const getIsActive = (id: string) => () => toastApi.isActive(id);
48+
const getClose = (id: string) => () => toastApi.close(id);
49+
const getGetState = (id: string) => () => $toastMap.get()[id] ?? null;
50+
51+
/**
52+
* Creates a toast with the given config. If the toast with the same id already exists, it will be updated.
53+
* When a toast is updated, its title, description, status and duration will be overwritten by the new config.
54+
* Set duration to `null` to make the toast persistent.
55+
* @param arg The toast config.
56+
* @returns An object with methods to get the toast state, close the toast and check if the toast is active
57+
*/
58+
export const toast = (arg: ToastArg): ToastApi => {
59+
// All toasts need an id, set a random one if not provided
60+
const id = arg.id ?? crypto.randomUUID();
61+
if (!arg.id) {
62+
arg.id = id;
63+
}
64+
if (arg.withCount === undefined) {
65+
arg.withCount = true;
66+
}
67+
let state = $toastMap.get()[arg.id];
68+
if (!state) {
69+
// First time caller, create and set the state
70+
state = { id, config: parseConfig(id, arg, 1), count: 1 };
71+
$toastMap.setKey(id, state);
72+
// Create the toast
73+
toastApi(state.config);
74+
} else {
75+
// This toast is already active, update its state
76+
state.count += 1;
77+
state.config = parseConfig(id, arg, state.count);
78+
$toastMap.setKey(id, state);
79+
// Update the toast itself
80+
toastApi.update(id, state.config);
81+
}
82+
return { getState: getGetState(id), close: getClose(id), isActive: getIsActive(id) };
83+
};
84+
85+
/**
86+
* Give a toast id, arg and current count, returns the parsed toast config (including dynamic title and description)
87+
* @param id The id of the toast
88+
* @param arg The arg passed to the toast function
89+
* @param count The current call count of the toast
90+
* @returns The parsed toast config
91+
*/
92+
const parseConfig = (id: string, arg: ToastArg, count: number): ToastConfig => {
93+
const title = arg.withCount && count > 1 ? `${arg.title} (${count})` : arg.title;
94+
const onCloseComplete = () => {
95+
$toastMap.setKey(id, undefined);
96+
if (arg.onCloseComplete) {
97+
arg.onCloseComplete();
98+
}
99+
};
100+
return { ...arg, title, onCloseComplete };
101+
};
102+
103+
/**
104+
* Enum of toast IDs that are often shared between multiple components (typo insurance)
105+
*/
106+
export const ToastID = z.enum([
107+
'MODEL_INSTALL_QUEUED',
108+
'MODEL_INSTALL_QUEUE_FAILED',
109+
'GRAPH_QUEUE_FAILED',
110+
'PARAMETER_SET',
111+
'PARAMETER_NOT_SET',
112+
]).enum;

0 commit comments

Comments
 (0)