From 683ec8e5f289c93d8fca488dbae654c2eb155ba2 Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Tue, 21 May 2024 19:33:24 +1000 Subject: [PATCH] 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. --- .../toast/ToastWithSessionRefDescription.tsx | 27 +++++ .../frontend/web/src/features/toast/toast.ts | 112 ++++++++++++++++++ 2 files changed, 139 insertions(+) create mode 100644 invokeai/frontend/web/src/features/toast/ToastWithSessionRefDescription.tsx create mode 100644 invokeai/frontend/web/src/features/toast/toast.ts diff --git a/invokeai/frontend/web/src/features/toast/ToastWithSessionRefDescription.tsx b/invokeai/frontend/web/src/features/toast/ToastWithSessionRefDescription.tsx new file mode 100644 index 0000000000..607dcaeb63 --- /dev/null +++ b/invokeai/frontend/web/src/features/toast/ToastWithSessionRefDescription.tsx @@ -0,0 +1,27 @@ +import { Flex, IconButton, Text } from '@invoke-ai/ui-library'; +import { t } from 'i18next'; +import { PiCopyBold } from 'react-icons/pi'; + +function onCopy(sessionId: string) { + navigator.clipboard.writeText(sessionId); +} + +type Props = { message: string; sessionId: string }; + +export default function ToastWithSessionRefDescription({ message, sessionId }: Props) { + return ( + + {message} + + {t('toast.sessionRef', { sessionId })} + } + onClick={onCopy.bind(null, sessionId)} + variant="ghost" + /> + + + ); +} diff --git a/invokeai/frontend/web/src/features/toast/toast.ts b/invokeai/frontend/web/src/features/toast/toast.ts new file mode 100644 index 0000000000..9970f5f18f --- /dev/null +++ b/invokeai/frontend/web/src/features/toast/toast.ts @@ -0,0 +1,112 @@ +import type { UseToastOptions } from '@invoke-ai/ui-library'; +import { createStandaloneToast, theme, TOAST_OPTIONS } from '@invoke-ai/ui-library'; +import { map } from 'nanostores'; +import { z } from 'zod'; + +const toastApi = createStandaloneToast({ + theme: theme, + defaultOptions: TOAST_OPTIONS.defaultOptions, +}).toast; + +// Slightly modified version of UseToastOptions +type ToastConfig = Omit & { + // Only string - Chakra allows numbers + id?: string; +}; + +type ToastArg = ToastConfig & { + /** + * Whether to append the number of times this toast has been shown to the title. Defaults to true. + * @example + * toast({ title: 'Hello', withCount: true }); + * // first toast: 'Hello' + * // second toast: 'Hello (2)' + */ + withCount?: boolean; +}; + +/* eslint-disable @typescript-eslint/no-explicit-any */ +// Any is correct here; we accept anything as toast data parameter. +type ToastInternalState = { + id: string; + config: ToastConfig; + count: number; +}; + +// We expose a limited API for the toast +type ToastApi = { + getState: () => ToastInternalState | null; + close: () => void; + isActive: () => boolean; +}; + +// Store each toast state by id, allowing toast consumers to not worry about persistent ids and updating and such +const $toastMap = map>({}); + +// Helpers to get the getters for the toast API +const getIsActive = (id: string) => () => toastApi.isActive(id); +const getClose = (id: string) => () => toastApi.close(id); +const getGetState = (id: string) => () => $toastMap.get()[id] ?? null; + +/** + * Creates a toast with the given config. If the toast with the same id already exists, it will be updated. + * When a toast is updated, its title, description, status and duration will be overwritten by the new config. + * Set duration to `null` to make the toast persistent. + * @param arg The toast config. + * @returns An object with methods to get the toast state, close the toast and check if the toast is active + */ +export const toast = (arg: ToastArg): ToastApi => { + // All toasts need an id, set a random one if not provided + const id = arg.id ?? crypto.randomUUID(); + if (!arg.id) { + arg.id = id; + } + if (arg.withCount === undefined) { + arg.withCount = true; + } + let state = $toastMap.get()[arg.id]; + if (!state) { + // First time caller, create and set the state + state = { id, config: parseConfig(id, arg, 1), count: 1 }; + $toastMap.setKey(id, state); + // Create the toast + toastApi(state.config); + } else { + // This toast is already active, update its state + state.count += 1; + state.config = parseConfig(id, arg, state.count); + $toastMap.setKey(id, state); + // Update the toast itself + toastApi.update(id, state.config); + } + return { getState: getGetState(id), close: getClose(id), isActive: getIsActive(id) }; +}; + +/** + * Give a toast id, arg and current count, returns the parsed toast config (including dynamic title and description) + * @param id The id of the toast + * @param arg The arg passed to the toast function + * @param count The current call count of the toast + * @returns The parsed toast config + */ +const parseConfig = (id: string, arg: ToastArg, count: number): ToastConfig => { + const title = arg.withCount && count > 1 ? `${arg.title} (${count})` : arg.title; + const onCloseComplete = () => { + $toastMap.setKey(id, undefined); + if (arg.onCloseComplete) { + arg.onCloseComplete(); + } + }; + return { ...arg, title, onCloseComplete }; +}; + +/** + * Enum of toast IDs that are often shared between multiple components (typo insurance) + */ +export const ToastID = z.enum([ + 'MODEL_INSTALL_QUEUED', + 'MODEL_INSTALL_QUEUE_FAILED', + 'GRAPH_QUEUE_FAILED', + 'PARAMETER_SET', + 'PARAMETER_NOT_SET', +]).enum;