From 3231210ab9bcd04efb584934b4ec8126eef7cf32 Mon Sep 17 00:00:00 2001 From: Matti Airas Date: Tue, 13 Aug 2024 16:29:27 +0300 Subject: [PATCH] Use toasts instead of modals for user feedback --- web/scss/styles.scss | 5 +- web/src/components/Header.tsx | 67 +++++++------------ web/src/components/ModalError.tsx | 57 ---------------- web/src/components/ToastMessage.tsx | 58 ++++++++++++++++ web/src/components/useToast.ts | 39 +++++++++++ web/src/index.tsx | 2 +- web/src/pages/Configuration/ConfigCard.tsx | 21 +++--- .../pages/SignalK/SignalKSettingsPanel.tsx | 44 +++++------- web/src/pages/Status/InfoGroups.tsx | 21 +++--- web/src/pages/System/index.tsx | 56 +++++++++------- web/src/pages/WiFi/WiFiSettingsPanel.tsx | 52 +++++++------- 11 files changed, 216 insertions(+), 206 deletions(-) delete mode 100644 web/src/components/ModalError.tsx create mode 100644 web/src/components/ToastMessage.tsx create mode 100644 web/src/components/useToast.ts diff --git a/web/scss/styles.scss b/web/scss/styles.scss index 2dfab9f58..b887aca29 100644 --- a/web/scss/styles.scss +++ b/web/scss/styles.scss @@ -69,8 +69,8 @@ $offcanvas-box-shadow: 0 1rem 3rem rgba(0, 0, 0, .175); //@import "bootstrap/scss/progress"; @import "bootstrap/scss/list-group"; @import "bootstrap/scss/close"; -// @import "bootstrap/scss/toasts"; -@import "bootstrap/scss/modal"; // Requires transitions +@import "bootstrap/scss/toasts"; +// @import "bootstrap/scss/modal"; // Requires transitions // @import "bootstrap/scss/tooltip"; //@import "bootstrap/scss/popover"; // @import "bootstrap/scss/carousel"; @@ -83,4 +83,3 @@ $offcanvas-box-shadow: 0 1rem 3rem rgba(0, 0, 0, .175); // Utilities @import "bootstrap/scss/utilities/api"; - diff --git a/web/src/components/Header.tsx b/web/src/components/Header.tsx index 9c6ab67cf..2d316b704 100644 --- a/web/src/components/Header.tsx +++ b/web/src/components/Header.tsx @@ -1,5 +1,4 @@ import { RouteInstruction } from "App"; -import Modal from "bootstrap/js/dist/modal"; import NavPathContext from "common/NavPathContext"; import { RestartRequiredContext, @@ -7,6 +6,7 @@ import { } from "common/RestartRequiredContext"; import { type JSX } from "preact"; import { useContext, useState } from "preact/hooks"; +import { ToastMessage } from "./ToastMessage"; function RouteLink({ route }: { route: RouteInstruction }): JSX.Element { const navPath = useContext(NavPathContext); @@ -32,14 +32,12 @@ type HeaderProps = { export function Header({ routes }: HeaderProps): JSX.Element { const { restartRequired, setRestartRequired } = useContext(RestartRequiredContext); - const [modalMessage, setModalMessage] = useState(""); + const [showToast, setShowToast] = useState(false); async function handleRestart(e: MouseEvent): Promise { e.preventDefault(); const response = await fetch("/api/device/restart", { method: "POST" }); - setModalMessage("Device is restarting."); - const modal = Modal.getOrCreateInstance(`#modalRestarting`); - modal.show(); + setShowToast(true); // Wait for the device to restart and then reload the page. setTimeout(() => { window.location.reload(); @@ -48,33 +46,14 @@ export function Header({ routes }: HeaderProps): JSX.Element { return ( <> -
-
-
-
-
Restarting
- -
-
-

{modalMessage}

-
-
- -
-
-
-
+ setShowToast(false)} + > + The device is restarting. Please wait... + +
diff --git a/web/src/components/ModalError.tsx b/web/src/components/ModalError.tsx deleted file mode 100644 index 878fb2ae4..000000000 --- a/web/src/components/ModalError.tsx +++ /dev/null @@ -1,57 +0,0 @@ -import Modal from "bootstrap/js/dist/modal"; -import { type JSX } from "preact"; -import { useEffect } from "preact/hooks"; - -interface ModalErrorProps { - id: string; - title: string; - children: React.ReactNode; - show: boolean; - onHide: () => void; -} - -export function ModalError({ - id, - title, - children, - show, - onHide, -}: ModalErrorProps): JSX.Element { - useEffect(() => { - if (show) { - const modal = Modal.getOrCreateInstance(`#${id}`); - modal.show(); - } - }); - - return ( - <> - - - ); -} diff --git a/web/src/components/ToastMessage.tsx b/web/src/components/ToastMessage.tsx new file mode 100644 index 000000000..4a6d79e8c --- /dev/null +++ b/web/src/components/ToastMessage.tsx @@ -0,0 +1,58 @@ +import { type JSX } from "preact"; +import { useEffect } from "preact/hooks"; +import useToast from "./useToast"; + +interface ToastMessageProps { + color: string; + children: React.ReactNode; + show: boolean; + autohide?: boolean; + delay?: number; + onHide: () => void; +} + +export function ToastMessage({ + color, + children, + show, + autohide = true, + delay = 5000, + onHide, +}: ToastMessageProps): JSX.Element { + const {toastRef, showToast, hideToast} = useToast({autohide, delay, onHide}); + + useEffect(() => { + if (show) { + showToast(); + } else { + hideToast(); + } + }, [show]); + + return ( + <> +
+
+
+
+ {children} +
+
+
+ + ); +} diff --git a/web/src/components/useToast.ts b/web/src/components/useToast.ts new file mode 100644 index 000000000..f61ca510b --- /dev/null +++ b/web/src/components/useToast.ts @@ -0,0 +1,39 @@ +import { Toast } from "bootstrap"; +import { useEffect, useRef, useState } from "react"; + +interface useToastProps { + autohide: boolean; + delay: number; + onHide: () => void; +} + +export default function useToast({ autohide, delay, onHide }: useToastProps) { + + const [isVisible, setIsVisible] = useState(false); + + const toastRef = useRef(null); + + useEffect(() => { + if (toastRef.current) { + const toastElement = new Toast(toastRef.current, { autohide, delay }); + // Call onHide when the toast is hidden to sync the state + toastRef.current.addEventListener("hidden.bs.toast", onHide); + + if (isVisible) { + toastElement.show(); + } else { + toastElement.hide(); + } + } + }, [isVisible]); + + const showToast = () => { + setIsVisible(true); + }; + + const hideToast = () => { + setIsVisible(false); + }; + + return {toastRef, showToast, hideToast}; +} diff --git a/web/src/index.tsx b/web/src/index.tsx index d02fc4be4..4d98d7d4b 100644 --- a/web/src/index.tsx +++ b/web/src/index.tsx @@ -9,7 +9,7 @@ import "../css/styles.css"; // import "bootstrap/dist/js/bootstrap.js"; import "bootstrap/js/dist/collapse"; -import "bootstrap/js/dist/modal"; import "bootstrap/js/dist/tab"; +import "bootstrap/js/dist/toast"; render(, document.body); diff --git a/web/src/pages/Configuration/ConfigCard.tsx b/web/src/pages/Configuration/ConfigCard.tsx index 086028b0e..65e92d053 100644 --- a/web/src/pages/Configuration/ConfigCard.tsx +++ b/web/src/pages/Configuration/ConfigCard.tsx @@ -1,8 +1,11 @@ import { APP_CONFIG } from "config"; import { useContext, useEffect, useId, useState } from "preact/hooks"; +import { + RestartRequiredContext, + RestartRequiredContextProps, +} from "common/RestartRequiredContext"; import { JsonValue, type JsonObject } from "common/jsonTypes"; -import { RestartRequiredContext, RestartRequiredContextProps } from "common/RestartRequiredContext"; import { Card } from "components/Card"; import { FormCheckboxInput, @@ -12,7 +15,7 @@ import { FormTextAreaInput, FormTextInput, } from "components/Form"; -import { ModalError } from "components/ModalError"; +import { ToastMessage } from "components/ToastMessage"; import { type JSX } from "preact/compat"; interface ItemsProps { @@ -385,16 +388,14 @@ export function ConfigCard({ path }: ConfigCardProps): JSX.Element | null { return ( <> - { - setHttpErrorText(""); - }} + onHide={() => setHttpErrorText("")} > - {httpErrorText} - +

Unable to set configuration:

+

{httpErrorText}

+
diff --git a/web/src/pages/SignalK/SignalKSettingsPanel.tsx b/web/src/pages/SignalK/SignalKSettingsPanel.tsx index ea8e98701..d0af65cf0 100644 --- a/web/src/pages/SignalK/SignalKSettingsPanel.tsx +++ b/web/src/pages/SignalK/SignalKSettingsPanel.tsx @@ -1,8 +1,8 @@ import { type JsonObject } from "common/jsonTypes"; import { Card } from "components/Card"; -import { ModalError } from "components/ModalError"; +import { ToastMessage } from "components/ToastMessage"; import { type JSX } from "preact"; -import { useContext, useEffect, useId, useState } from "preact/hooks"; +import { useEffect, useId, useState } from "preact/hooks"; import { fetchConfigData, saveConfigData } from "../../common/configAPIClient"; import { Collapse } from "../../components/Collapse"; @@ -10,26 +10,19 @@ const CONFIG_PATH = "/System/Signal K Settings"; export function SignalKSettingsPanel(): JSX.Element { const [config, setConfig] = useState({}); - const [requestSave, setRequestSave] = useState(false); const [errorText, setErrorText] = useState(""); const id = useId(); - function handleError(e: Error): void { - setErrorText(e.message); - } - - useEffect(() => { - if (requestSave) { - // save config data to server - void saveConfigData( - CONFIG_PATH, - JSON.stringify(config), - handleError, - ); - setRequestSave(false); + async function handleSave(): Promise { + try { + await saveConfigData(CONFIG_PATH, JSON.stringify(config), (e: Error) => { + setErrorText(e.message); + }); + } catch (e) { + setErrorText(e.message); } - }, [config, requestSave]); + } async function updateConfig(): Promise { try { @@ -48,27 +41,24 @@ export function SignalKSettingsPanel(): JSX.Element { return ( <> - { - setErrorText(""); - }} + onHide={() => setErrorText("")} >

{errorText}

-
+
@@ -201,7 +191,7 @@ function SKAuthToken({

Click the button to clear the Signal K authentication token. This causes - the device to request a new token from the Signal K server. + the device to request re-authorization from the Signal K server.