diff --git a/src/App.tsx b/src/App.tsx index c268ac8f..55a77528 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -9,6 +9,7 @@ import { Dashboard } from "@components/Dashboard.js"; import { useDeviceStore } from "@core/stores/deviceStore.js"; import { ThemeController } from "@components/generic/ThemeController.js"; import { NewDeviceDialog } from "@components/Dialog/NewDeviceDialog.js"; +import { Toaster } from "@components/Toaster.js"; export const App = (): JSX.Element => { const { getDevice } = useDeviceStore(); @@ -25,6 +26,7 @@ export const App = (): JSX.Element => { setConnectDialogOpen(open); }} /> +
diff --git a/src/components/Toaster.tsx b/src/components/Toaster.tsx new file mode 100644 index 00000000..b3337a05 --- /dev/null +++ b/src/components/Toaster.tsx @@ -0,0 +1,34 @@ +import { useToast } from "@core/hooks/useToast.js"; + +import { + Toast, + ToastClose, + ToastDescription, + ToastProvider, + ToastTitle, + ToastViewport +} from "@components/UI/Toast.js"; + +export function Toaster() { + const { toasts } = useToast(); + + return ( + + {toasts.map(function ({ id, title, description, action, ...props }) { + return ( + +
+ {title && {title}} + {description && ( + {description} + )} +
+ {action} + +
+ ); + })} + +
+ ); +} diff --git a/src/core/hooks/useToast.ts b/src/core/hooks/useToast.ts new file mode 100644 index 00000000..2995d623 --- /dev/null +++ b/src/core/hooks/useToast.ts @@ -0,0 +1,187 @@ +import * as React from "react"; + +import type { ToastActionElement, ToastProps } from "@components/UI/Toast.js"; + +const TOAST_LIMIT = 1; +const TOAST_REMOVE_DELAY = 1000; + +type ToasterToast = ToastProps & { + id: string; + title?: React.ReactNode; + description?: React.ReactNode; + action?: ToastActionElement; +}; + +const actionTypes = { + ADD_TOAST: "ADD_TOAST", + UPDATE_TOAST: "UPDATE_TOAST", + DISMISS_TOAST: "DISMISS_TOAST", + REMOVE_TOAST: "REMOVE_TOAST" +} as const; + +let count = 0; + +function genId() { + count = (count + 1) % Number.MAX_VALUE; + return count.toString(); +} + +type ActionType = typeof actionTypes; + +type Action = + | { + type: ActionType["ADD_TOAST"]; + toast: ToasterToast; + } + | { + type: ActionType["UPDATE_TOAST"]; + toast: Partial; + } + | { + type: ActionType["DISMISS_TOAST"]; + toastId?: ToasterToast["id"]; + } + | { + type: ActionType["REMOVE_TOAST"]; + toastId?: ToasterToast["id"]; + }; + +interface State { + toasts: ToasterToast[]; +} + +const toastTimeouts = new Map>(); + +const addToRemoveQueue = (toastId: string) => { + if (toastTimeouts.has(toastId)) { + return; + } + + const timeout = setTimeout(() => { + toastTimeouts.delete(toastId); + dispatch({ + type: "REMOVE_TOAST", + toastId: toastId + }); + }, TOAST_REMOVE_DELAY); + + toastTimeouts.set(toastId, timeout); +}; + +export const reducer = (state: State, action: Action): State => { + switch (action.type) { + case "ADD_TOAST": + return { + ...state, + toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT) + }; + + case "UPDATE_TOAST": + return { + ...state, + toasts: state.toasts.map((t) => + t.id === action.toast.id ? { ...t, ...action.toast } : t + ) + }; + + case "DISMISS_TOAST": + const { toastId } = action; + + // ! Side effects ! - This could be extracted into a dismissToast() action, + // but I'll keep it here for simplicity + if (toastId) { + addToRemoveQueue(toastId); + } else { + state.toasts.forEach((toast) => { + addToRemoveQueue(toast.id); + }); + } + + return { + ...state, + toasts: state.toasts.map((t) => + t.id === toastId || toastId === undefined + ? { + ...t, + open: false + } + : t + ) + }; + case "REMOVE_TOAST": + if (action.toastId === undefined) { + return { + ...state, + toasts: [] + }; + } + return { + ...state, + toasts: state.toasts.filter((t) => t.id !== action.toastId) + }; + } +}; + +const listeners: Array<(state: State) => void> = []; + +let memoryState: State = { toasts: [] }; + +function dispatch(action: Action) { + memoryState = reducer(memoryState, action); + listeners.forEach((listener) => { + listener(memoryState); + }); +} + +interface Toast extends Omit {} + +function toast({ ...props }: Toast) { + const id = genId(); + + const update = (props: ToasterToast) => + dispatch({ + type: "UPDATE_TOAST", + toast: { ...props, id } + }); + const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id }); + + dispatch({ + type: "ADD_TOAST", + toast: { + ...props, + id, + open: true, + onOpenChange: (open) => { + if (!open) dismiss(); + } + } + }); + + return { + id: id, + dismiss, + update + }; +} + +function useToast() { + const [state, setState] = React.useState(memoryState); + + React.useEffect(() => { + listeners.push(setState); + return () => { + const index = listeners.indexOf(setState); + if (index > -1) { + listeners.splice(index, 1); + } + }; + }, [state]); + + return { + ...state, + toast, + dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }) + }; +} + +export { useToast, toast }; diff --git a/src/pages/Config/index.tsx b/src/pages/Config/index.tsx index ba8ebac6..d80680fd 100644 --- a/src/pages/Config/index.tsx +++ b/src/pages/Config/index.tsx @@ -7,12 +7,14 @@ import { SidebarSection } from "@components/UI/Sidebar/SidebarSection.js"; import { useState } from "react"; import { useDevice } from "@app/core/stores/deviceStore.js"; import { SidebarButton } from "@components/UI/Sidebar/sidebarButton.js"; +import { useToast } from "@core/hooks/useToast.js"; export const ConfigPage = (): JSX.Element => { const { workingConfig, workingModuleConfig, connection } = useDevice(); const [activeConfigSection, setActiveConfigSection] = useState< "device" | "module" >("device"); + const { toast } = useToast(); return ( <> @@ -42,11 +44,21 @@ export const ConfigPage = (): JSX.Element => { async onClick() { if (activeConfigSection === "device") { workingConfig.map( - async (config) => await connection?.setConfig(config) + async (config) => + await connection?.setConfig(config).then(() => + toast({ + title: `Config ${config.payloadVariant.case} saved` + }) + ) ); } else { workingModuleConfig.map( - async (config) => await connection?.setModuleConfig(config) + async (moduleConfig) => + await connection?.setModuleConfig(moduleConfig).then(() => + toast({ + title: `Config ${moduleConfig.payloadVariant.case} saved` + }) + ) ); }