Remove InputDialog and replace with new AlertContext implementation

This commit is contained in:
Mathias Wagner
2026-01-20 20:33:07 +01:00
parent 0cc44355e8
commit ec16b87aa6
8 changed files with 339 additions and 204 deletions

View File

@@ -0,0 +1,178 @@
import React, {createContext, useCallback, useContext, useEffect, useMemo, useRef, useState} from "react";
import {createPortal} from "react-dom";
import {FontAwesomeIcon} from "@fortawesome/react-fontawesome";
import {faClose} from "@fortawesome/free-solid-svg-icons";
const AlertContext = createContext(null);
export const useAlert = () => {
const context = useContext(AlertContext);
if (!context) throw new Error("useAlert must be used within AlertProvider");
return context;
};
export const AlertProvider = ({children}) => {
const [alerts, setAlerts] = useState([]);
const alertIdRef = useRef(0);
const resolversRef = useRef(new Map());
const showAlert = useCallback((config) => {
return new Promise((resolve) => {
const id = ++alertIdRef.current;
resolversRef.current.set(id, resolve);
setAlerts(prev => [...prev, {...config, id}]);
});
}, []);
const closeAlert = useCallback((id, result = null) => {
const resolver = resolversRef.current.get(id);
if (resolver) {
resolver(result);
resolversRef.current.delete(id);
}
setAlerts(prev => prev.filter(a => a.id !== id));
}, []);
const openAlert = useCallback((title, description, options = {}) =>
showAlert({
type: "alert",
title,
description,
buttonText: options.buttonText || "OK", ...options
}), [showAlert]);
const openInput = useCallback((title, options = {}) =>
showAlert({type: "input", title, ...options}), [showAlert]);
const openSelect = useCallback((title, selectOptions, options = {}) =>
showAlert({
type: "select",
title,
options: selectOptions,
value: options.value || Object.keys(selectOptions)[0], ...options
}), [showAlert]);
const openConfirm = useCallback((title, description, options = {}) =>
showAlert({
type: "confirm",
title,
description,
buttonText: options.buttonText || "OK", ...options
}), [showAlert]);
const contextValue = useMemo(() => ({
openAlert, openInput, openSelect, openConfirm
}), [openAlert, openInput, openSelect, openConfirm]);
return (
<AlertContext.Provider value={contextValue}>
{children}
{alerts.map(alert => (
<AlertRenderer key={alert.id} alert={alert} onClose={(result) => closeAlert(alert.id, result)}/>
))}
</AlertContext.Provider>
);
};
const AlertRenderer = ({alert, onClose}) => {
const areaRef = useRef();
const dialogRef = useRef();
const [inputValue, setInputValue] = useState(alert.value || "");
const [inputError, setInputError] = useState(false);
const closeResultRef = useRef(null);
const isClosingRef = useRef(false);
const close = useCallback((result = null) => {
if (alert.disableClose && result === null) return;
if (isClosingRef.current) return;
isClosingRef.current = true;
closeResultRef.current = result;
areaRef.current?.classList.add("dialog-area-hidden");
dialogRef.current?.classList.add("dialog-hidden");
}, [alert.disableClose]);
const handleAnimationEnd = (e) => {
if (e.animationName === "fadeOut") onClose(closeResultRef.current);
};
const handleBackdropClick = (e) => {
if (e.target === areaRef.current) close();
};
const handleKeyDown = useCallback((e) => {
if (e.key === "Escape" && !alert.disableClose) {
e.preventDefault();
close();
}
if (e.key === "Enter") {
e.preventDefault();
handleSubmit();
}
}, [alert, inputValue]);
useEffect(() => {
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [handleKeyDown]);
const handleSubmit = () => {
if (alert.type === "input" && alert.required && !inputValue) {
setInputError(true);
return;
}
const result = alert.type === "input" || alert.type === "select" ? inputValue : true;
alert.onSuccess?.(result);
close(result);
};
return createPortal(
<div className="dialog-area" ref={areaRef} onClick={handleBackdropClick}>
<div className="dialog" ref={dialogRef} onAnimationEnd={handleAnimationEnd}>
<div className="dialog-header">
<h4 className="dialog-text">{alert.title}</h4>
{!alert.disableClose &&
<FontAwesomeIcon icon={faClose} className="dialog-text dialog-icon" onClick={() => close()}/>}
</div>
<div className="dialog-main">
{alert.description && <p className="dialog-description">{alert.description}</p>}
{alert.type === "input" && (
<input className={`dialog-input${inputError ? " input-error" : ""}`}
type={alert.inputType || "text"}
placeholder={alert.placeholder} value={inputValue} autoFocus
onChange={(e) => {
setInputValue(e.target.value);
setInputError(false);
}}/>
)}
{alert.type === "select" && (
<select className="dialog-input" value={inputValue}
onChange={(e) => setInputValue(e.target.value)}>
{Object.entries(alert.options || {}).map(([key, label]) => (
<option key={key} value={key}>{label}</option>
))}
</select>
)}
</div>
<div className="dialog-buttons">
{alert.clearButton && (
<button className="dialog-btn dialog-secondary" onClick={() => {
alert.onClear?.();
close();
}}>
{alert.clearButton}
</button>
)}
{alert.type === "confirm" && (
<button className="dialog-btn dialog-secondary" onClick={() => close(false)}>
{alert.cancelText || "Cancel"}
</button>
)}
<button className={`dialog-btn${alert.danger ? " dialog-danger" : ""}`} onClick={handleSubmit}>
{alert.buttonText || "OK"}
</button>
</div>
</div>
</div>,
document.body
);
};

View File

@@ -0,0 +1 @@
export {AlertProvider, useAlert} from './AlertContext';

View File

@@ -1,5 +1,5 @@
import React, {createContext, useContext, useEffect, useState} from "react";
import {InputDialogContext} from "../InputDialog";
import React, {createContext, useEffect, useState} from "react";
import {useAlert} from "../Alert";
import {request} from "@/common/utils/RequestUtil";
import {apiErrorDialog, passwordRequiredDialog} from "@/common/contexts/Config/dialog";
import WelcomeDialog from "@/common/components/WelcomeDialog";
@@ -9,7 +9,7 @@ export const ConfigContext = createContext({});
export const ConfigProvider = (props) => {
const [config, setConfig] = useState({});
const [setDialog] = useContext(InputDialogContext);
const alert = useAlert();
const [welcomeShown, setWelcomeShown] = useState(false);
const navigate = useNavigate();
@@ -30,10 +30,31 @@ export const ConfigProvider = (props) => {
? navigate("/nodes") : setConfig(result);
}).catch((code) => {
localStorage.getItem("currentNode") !== null && localStorage.getItem("currentNode") !== "0"
? navigate("/nodes") : setDialog(code === 1 ? passwordRequiredDialog() : apiErrorDialog());
? navigate("/nodes") : showErrorDialog(code);
});
}
const showErrorDialog = async (code) => {
const dialogConfig = code === 1 ? passwordRequiredDialog() : apiErrorDialog();
if (code === 1) {
const result = await alert.openInput(dialogConfig.title, {
placeholder: dialogConfig.placeholder,
description: dialogConfig.description,
inputType: dialogConfig.type,
buttonText: dialogConfig.buttonText,
disableClose: dialogConfig.disableCloseButton
});
if (result) dialogConfig.onSuccess(result);
} else {
await alert.openAlert(dialogConfig.title, dialogConfig.description, {
buttonText: dialogConfig.buttonText,
disableClose: dialogConfig.disableCloseButton
});
dialogConfig.onSuccess();
}
};
const checkConfig = async () => (await request("/config")).json();
useEffect(reloadConfig, []);
@@ -45,7 +66,7 @@ export const ConfigProvider = (props) => {
return (
<ConfigContext.Provider value={[config, reloadConfig, checkConfig]}>
{welcomeShown && <WelcomeDialog onClose={() => setWelcomeShown(false)}/>}
<WelcomeDialog open={welcomeShown} onClose={() => setWelcomeShown(false)}/>
{props.children}
</ConfigContext.Provider>
)

View File

@@ -1,54 +1,77 @@
import React, {createContext, useEffect, useRef} from "react";
import React, {useCallback, useEffect, useRef, useState} from "react";
import {createPortal} from "react-dom";
import {FontAwesomeIcon} from "@fortawesome/react-fontawesome";
import {faClose} from "@fortawesome/free-solid-svg-icons";
import "./styles.sass";
export const DialogContext = createContext({});
export const DialogProvider = (props) => {
export const Dialog = ({open, onClose, className, disableClose, children}) => {
const areaRef = useRef();
const ref = useRef();
const close = (force = false) => {
if (props.disableClosing && !force) return;
areaRef.current?.classList.add("dialog-area-hidden");
ref.current?.classList.add("dialog-hidden");
}
const onClose = (e) => {
if (e.animationName === "fadeOut") {
hideTooltips(false);
props?.close();
}
}
const handleKeyDown = (e) => {
if (e.code === "Enter" && props.submit) props.submit();
}
const hideTooltips = (state) => Array.from(document.getElementsByClassName("tooltip")).forEach(element => {
if (state && !element.classList.contains("tooltip-invisible"))
element.classList.add("tooltip-invisible");
if (!state && element.classList.contains("tooltip-invisible"))
element.classList.remove("tooltip-invisible");
});
const dialogRef = useRef();
const [visible, setVisible] = useState(false);
const isClosingRef = useRef(false);
useEffect(() => {
const handleClick = (event) => {
if (!ref.current?.contains(event.target)) close();
if (open && !visible) {
setVisible(true);
isClosingRef.current = false;
} else if (!open && visible && !isClosingRef.current) {
isClosingRef.current = true;
areaRef.current?.classList.add("dialog-area-hidden");
dialogRef.current?.classList.add("dialog-hidden");
}
}, [open, visible]);
document.addEventListener("mousedown", handleClick);
const handleClose = useCallback(() => {
if (disableClose || isClosingRef.current) return;
isClosingRef.current = true;
areaRef.current?.classList.add("dialog-area-hidden");
dialogRef.current?.classList.add("dialog-hidden");
}, [disableClose]);
return () => document.removeEventListener("mousedown", handleClick);
}, [ref]);
const handleAnimationEnd = (e) => {
if (e.animationName === "fadeOut") {
setVisible(false);
isClosingRef.current = false;
onClose?.();
}
};
return (
<DialogContext.Provider value={close}>
<div className="dialog-area" ref={areaRef}>
<div className={"dialog" + (props.customClass ? " " + props.customClass : "")} ref={ref}
onAnimationEnd={onClose} onKeyDown={handleKeyDown} onAnimationStart={() => hideTooltips(true)}>
{props.children}
</div>
const handleBackdropClick = (e) => {
if (e.target === areaRef.current) handleClose();
};
useEffect(() => {
if (!visible) return;
const handleKeyDown = (e) => {
if (e.key === "Escape" && !disableClose) {
e.preventDefault();
handleClose();
}
};
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [visible, disableClose, handleClose]);
if (!visible) return null;
return createPortal(
<div className="dialog-area" ref={areaRef} onClick={handleBackdropClick}>
<div className={`dialog${className ? ` ${className}` : ""}`} ref={dialogRef}
onAnimationEnd={handleAnimationEnd}>
{typeof children === "function" ? children({close: handleClose}) : children}
</div>
</DialogContext.Provider>
)
}
</div>,
document.body
);
};
export const DialogHeader = ({children, onClose, disableClose}) => (
<div className="dialog-header">
<h4 className="dialog-text">{children}</h4>
{!disableClose && <FontAwesomeIcon icon={faClose} className="dialog-text dialog-icon" onClick={onClose}/>}
</div>
);
export const DialogBody = ({children}) => <div className="dialog-main">{children}</div>;
export const DialogFooter = ({children}) => <div className="dialog-buttons">{children}</div>;

View File

@@ -28,9 +28,16 @@
-webkit-backdrop-filter: blur(4px)
border: 1px solid $light-gray
border-radius: 1rem
transition: all 0.2s
animation: fadeIn 0.3s
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.3)
min-width: 300px
max-width: min(500px, 90vw)
box-sizing: border-box
&.storage-dialog-wrapper,
&.provider-dialog-wrapper,
&.integration-dialog
max-width: 90vw
.dialog-hidden
visibility: hidden
@@ -56,6 +63,8 @@
justify-content: center
align-items: center
flex-direction: column
width: 100%
box-sizing: border-box
.dialog-buttons
display: flex
@@ -69,10 +78,13 @@
margin: 0
.dialog-description
font-size: 15pt
font-size: 13pt
margin: 12px 2px 2px
color: $subtext
line-height: 1.5
word-wrap: break-word
overflow-wrap: break-word
max-width: 100%
.dialog-description a
color: $accent-primary
@@ -115,4 +127,56 @@
.dialog-secondary
&:hover
border-color: $accent-danger
color: $accent-danger
color: $accent-danger
.dialog-input
font-size: 0.9rem
padding: 0.6rem 0.875rem
font-weight: 500
margin-top: 15px
margin-bottom: 15px
width: 100%
background-color: $darker-gray
color: $white
border: 1px solid $light-gray
border-radius: 0.5rem
text-align: center
box-sizing: border-box
outline: none
transition: all 0.15s ease
.dialog-input::placeholder
color: $subtext
.dialog-input:focus
border-color: $accent-primary
.input-error
border-color: $accent-danger
.input-error:focus
border-color: $accent-danger
.dialog-loading
min-width: 200px
min-height: 100px
display: flex
align-items: center
justify-content: center
@media (max-width: 600px)
.dialog
min-width: 280px
max-width: 95vw
padding: 1rem
font-size: 0.9rem
.dialog-text
font-size: 13pt
.dialog-description
font-size: 12pt
.dialog-btn
font-size: 11pt
padding: 8px 14px

View File

@@ -1,119 +0,0 @@
import React, {createContext, useContext, useEffect, useState} from "react";
import {FontAwesomeIcon} from "@fortawesome/react-fontawesome";
import {faClose} from "@fortawesome/free-solid-svg-icons";
import {t} from "i18next";
import {DialogContext, DialogProvider} from "@/common/contexts/Dialog";
import "./styles.sass";
export const InputDialogContext = createContext({});
const DialogArea = ({dialog}) => {
const close = useContext(DialogContext);
const [value, setValue] = useState("");
const [error, setError] = useState(false);
useEffect(() => {
if (dialog.value) setValue(dialog.value);
}, [dialog.value]);
useEffect(() => {
document.onkeyup = e => {
if (e.key === "Enter") {
e.preventDefault();
submit();
}
if (e.key === "Escape" && !dialog.disableCloseButton) {
e.preventDefault();
closeDialog();
}
}
return () => {
document.onkeyup = null;
}
});
function updateValue(e) {
if (dialog.updateDescription) dialog.description = dialog.updateDescription(e.target.value);
setValue(e.target.value);
}
function closeDialog() {
close();
if (dialog.onClose) dialog.onClose();
}
function submit() {
if (!dialog.description && !value) {
setError(true);
return;
}
close(true);
if (dialog.onSuccess) dialog.onSuccess(value);
}
function clear() {
close();
if (dialog.onClear) dialog.onClear();
}
return (
<>
<div className="dialog-header">
<h4 className="dialog-text">{dialog.title}</h4>
{!dialog.disableCloseButton ?
<FontAwesomeIcon icon={faClose} className="dialog-text dialog-icon" onClick={closeDialog}/> : <></>}
</div>
<div className="dialog-main">
{dialog.description ? <h3 className="dialog-description">{dialog.description}</h3> : ""}
{dialog.placeholder ? <input className={"dialog-input" + (error ? " input-error" : "")}
type={dialog.type ? dialog.type : "text"}
placeholder={dialog.placeholder} value={value}
onChange={updateValue}/> : ""}
{dialog.select ? <select value={value} onChange={updateValue} className="dialog-input">
{Object.keys(dialog.selectOptions).map(key => <option key={key}
value={key}>{dialog.selectOptions[key]}</option>)}
</select> : ""}
</div>
<div className="dialog-buttons">
{dialog.unsetButton ? <button className="dialog-btn dialog-secondary"
onClick={clear}>{dialog.unsetButton || t("dialog.unset")}</button> : ""}
<button className={"dialog-btn"+(dialog.mainRed ? " dialog-secondary" : "")} onClick={submit}>{dialog.buttonText || t("dialog.update")}</button>
</div>
</>
)
}
export const InputDialogProvider = (props) => {
const [dialog, setDialog] = useState();
const [isDialogOpen, setIsDialogOpen] = useState(false);
const [dialogList, setDialogList] = useState([]);
const updateDialog = (newDialog) => newDialog ? setDialogList([...dialogList, newDialog]) : "";
useEffect(() => {
if (dialogList.length === 0) return;
if ((!isDialogOpen && dialogList[0]) || dialogList[0].replace) {
setDialog(dialogList[0]);
setDialogList(dialogList.slice(1));
setIsDialogOpen(true);
}
}, [isDialogOpen, dialogList]);
const handleClose = () => {
setIsDialogOpen(false);
setDialog();
};
return (
<InputDialogContext.Provider value={[updateDialog]}>
{dialog && (
<DialogProvider close={handleClose} customClass="input-dialog"
disableClosing={dialog.disableCloseButton}>
<DialogArea dialog={dialog}/>
</DialogProvider>
)}
{props.children}
</InputDialogContext.Provider>
);
};

View File

@@ -1 +0,0 @@
export * from './InputDialog';

View File

@@ -1,32 +0,0 @@
@use "@/common/styles/colors" as *
.input-dialog
width: 400px
.dialog-input
font-size: 0.9rem
padding: 0.6rem 0.875rem
font-weight: 500
margin-top: 15px
margin-bottom: 15px
width: 100%
background-color: $darker-gray
color: $white
border: 1px solid $light-gray
border-radius: 0.5rem
text-align: center
box-sizing: border-box
outline: none
transition: all 0.15s ease
.dialog-input::placeholder
color: $subtext
.dialog-input:focus
border-color: $accent-primary
.input-error
border-color: $accent-danger
.input-error:focus
border-color: $accent-danger