mirror of
https://github.com/meshtastic/web.git
synced 2026-04-29 10:14:28 -04:00
Dialog abstraction using DialogWrapper (#830)
* feat: add dialog abstraction system * removed unneeded file * fix formatting * linting fixes
This commit is contained in:
@@ -1,16 +1,7 @@
|
||||
import { Button } from "@components/UI/Button.tsx";
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@components/UI/Dialog.tsx";
|
||||
import { useMessages } from "@core/stores";
|
||||
import { useMessages } from "@app/core/stores/index.ts";
|
||||
import { AlertTriangleIcon } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { DialogWrapper } from "../DialogWrapper.tsx";
|
||||
|
||||
export interface DeleteMessagesDialogProps {
|
||||
open: boolean;
|
||||
@@ -22,40 +13,25 @@ export const DeleteMessagesDialog = ({
|
||||
onOpenChange,
|
||||
}: DeleteMessagesDialogProps) => {
|
||||
const { t } = useTranslation("dialog");
|
||||
const { deleteAllMessages } = useMessages();
|
||||
const handleCloseDialog = () => {
|
||||
const messageStore = useMessages();
|
||||
|
||||
const handleConfirm = () => {
|
||||
messageStore.deleteAllMessages();
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogClose data-testid="dialog-close-button" />
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<AlertTriangleIcon className="h-5 w-5 text-warning" />
|
||||
{t("deleteMessages.title")}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t("deleteMessages.description")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter className="mt-4">
|
||||
<Button variant="outline" onClick={handleCloseDialog} name="dismiss">
|
||||
{t("button.dismiss")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() => {
|
||||
deleteAllMessages();
|
||||
handleCloseDialog();
|
||||
}}
|
||||
name="clearMessages"
|
||||
>
|
||||
{t("button.clearMessages")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<DialogWrapper
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
type="confirm"
|
||||
title={t("deleteMessages.title")}
|
||||
description={t("deleteMessages.description")}
|
||||
icon={<AlertTriangleIcon className="h-5 w-5 text-warning" />}
|
||||
variant="destructive"
|
||||
confirmText={t("button.clearMessages")}
|
||||
cancelText={t("button.dismiss")}
|
||||
onConfirm={handleConfirm}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
141
packages/web/src/components/Dialog/DialogWrapper.tsx
Normal file
141
packages/web/src/components/Dialog/DialogWrapper.tsx
Normal file
@@ -0,0 +1,141 @@
|
||||
import { Button } from "@components/UI/Button.tsx";
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@components/UI/Dialog.tsx";
|
||||
import { Separator } from "@components/UI/Separator.tsx";
|
||||
import type { ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export type DialogVariant = "default" | "destructive";
|
||||
export type DialogType = "confirm" | "alert" | "info" | "custom";
|
||||
|
||||
export interface DialogWrapperProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
title: string;
|
||||
description?: string;
|
||||
type?: DialogType;
|
||||
icon?: ReactNode;
|
||||
children?: ReactNode;
|
||||
|
||||
showFooter?: boolean;
|
||||
confirmText?: string;
|
||||
cancelText?: string;
|
||||
dismissText?: string;
|
||||
variant?: DialogVariant;
|
||||
confirmIcon?: ReactNode;
|
||||
customFooter?: ReactNode;
|
||||
|
||||
onConfirm?: () => void | Promise<void>;
|
||||
onCancel?: () => void;
|
||||
onDismiss?: () => void;
|
||||
}
|
||||
|
||||
export const DialogWrapper = ({
|
||||
open,
|
||||
onOpenChange,
|
||||
title,
|
||||
description,
|
||||
type = "custom",
|
||||
icon,
|
||||
children,
|
||||
showFooter = true,
|
||||
confirmText,
|
||||
cancelText,
|
||||
dismissText,
|
||||
variant = "default",
|
||||
confirmIcon,
|
||||
customFooter,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
onDismiss,
|
||||
}: DialogWrapperProps) => {
|
||||
const { t } = useTranslation("dialog");
|
||||
|
||||
const handleClose = () => {
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
const handleConfirm = async () => {
|
||||
if (onConfirm) {
|
||||
await onConfirm();
|
||||
}
|
||||
handleClose();
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
if (onCancel) {
|
||||
onCancel();
|
||||
}
|
||||
handleClose();
|
||||
};
|
||||
|
||||
const handleDismiss = () => {
|
||||
if (onDismiss) {
|
||||
onDismiss();
|
||||
}
|
||||
handleClose();
|
||||
};
|
||||
|
||||
const renderFooter = () => {
|
||||
if (!showFooter) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (customFooter) {
|
||||
return <DialogFooter className="mt-4">{customFooter}</DialogFooter>;
|
||||
}
|
||||
|
||||
switch (type) {
|
||||
case "confirm":
|
||||
return (
|
||||
<DialogFooter className="mt-4">
|
||||
<Button variant="outline" onClick={handleCancel} name="cancel">
|
||||
{cancelText || t("button.cancel")}
|
||||
</Button>
|
||||
<Button variant={variant} onClick={handleConfirm} name="confirm">
|
||||
{confirmIcon && <span className="mr-2">{confirmIcon}</span>}
|
||||
{confirmText || t("button.confirm")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
);
|
||||
|
||||
case "alert":
|
||||
case "info":
|
||||
return (
|
||||
<DialogFooter className="mt-4">
|
||||
<Button variant="outline" onClick={handleDismiss} name="dismiss">
|
||||
{dismissText || t("button.dismiss")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
);
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogClose />
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
{icon && icon}
|
||||
{title}
|
||||
</DialogTitle>
|
||||
{type !== "custom" && <Separator />}
|
||||
{description && <DialogDescription>{description}</DialogDescription>}
|
||||
</DialogHeader>
|
||||
{children}
|
||||
{renderFooter()}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -1,16 +1,7 @@
|
||||
import { Button } from "@components/UI/Button.tsx";
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@components/UI/Dialog.tsx";
|
||||
import { Label } from "@components/UI/Label.tsx";
|
||||
import { useAppStore, useDevice, useNodeDB } from "@core/stores";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { DialogWrapper } from "./DialogWrapper.tsx";
|
||||
|
||||
export interface RemoveNodeDialogProps {
|
||||
open: boolean;
|
||||
@@ -26,35 +17,25 @@ export const RemoveNodeDialog = ({
|
||||
const { getNode, removeNode } = useNodeDB();
|
||||
const { nodeNumToBeRemoved } = useAppStore();
|
||||
|
||||
const onSubmit = () => {
|
||||
const handleConfirm = () => {
|
||||
connection?.removeNodeByNum(nodeNumToBeRemoved);
|
||||
removeNode(nodeNumToBeRemoved);
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogClose />
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("removeNode.title")}</DialogTitle>
|
||||
<DialogDescription>{t("removeNode.description")}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="gap-4">
|
||||
<form onSubmit={onSubmit}>
|
||||
<Label>{getNode(nodeNumToBeRemoved)?.user?.longName}</Label>
|
||||
</form>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="destructive"
|
||||
name="remove"
|
||||
onClick={() => onSubmit()}
|
||||
>
|
||||
{t("button.remove")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<DialogWrapper
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
type="confirm"
|
||||
title={t("removeNode.title")}
|
||||
description={t("removeNode.description")}
|
||||
variant="destructive"
|
||||
confirmText={t("button.remove")}
|
||||
onConfirm={handleConfirm}
|
||||
>
|
||||
<div className="gap-4">
|
||||
<Label>{getNode(nodeNumToBeRemoved)?.user?.longName}</Label>
|
||||
</div>
|
||||
</DialogWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,17 +1,10 @@
|
||||
import { Button } from "@components/UI/Button.tsx";
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@components/UI/Dialog.tsx";
|
||||
import { Input } from "@components/UI/Input.tsx";
|
||||
import { useDevice } from "@core/stores";
|
||||
import { ClockIcon, PowerIcon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { DialogWrapper } from "./DialogWrapper.tsx";
|
||||
|
||||
export interface ShutdownDialogProps {
|
||||
open: boolean;
|
||||
@@ -21,45 +14,40 @@ export interface ShutdownDialogProps {
|
||||
export const ShutdownDialog = ({ open, onOpenChange }: ShutdownDialogProps) => {
|
||||
const { t } = useTranslation("dialog");
|
||||
const { connection } = useDevice();
|
||||
|
||||
const [time, setTime] = useState<number>(5);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogClose />
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("shutdown.title")}</DialogTitle>
|
||||
<DialogDescription>{t("shutdown.description")}</DialogDescription>
|
||||
</DialogHeader>
|
||||
const handleScheduledShutdown = () => {
|
||||
connection?.shutdown(time * 60).then(() => onOpenChange(false));
|
||||
};
|
||||
|
||||
<div className="flex gap-2 p-4">
|
||||
<Input
|
||||
type="number"
|
||||
value={time}
|
||||
onChange={(e) => setTime(Number.parseInt(e.target.value))}
|
||||
suffix={t("unit.minute.plural")}
|
||||
/>
|
||||
<Button
|
||||
className="w-24"
|
||||
onClick={() => {
|
||||
connection?.shutdown(time * 60).then(() => onOpenChange(false));
|
||||
}}
|
||||
>
|
||||
<ClockIcon size={16} />
|
||||
</Button>
|
||||
<Button
|
||||
className="w-24"
|
||||
name="now"
|
||||
onClick={() => {
|
||||
connection?.shutdown(2).then(() => () => onOpenChange(false));
|
||||
}}
|
||||
>
|
||||
<PowerIcon className="mr-2" size={16} />
|
||||
{t("button.now")}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
const handleImmediateShutdown = () => {
|
||||
connection?.shutdown(2).then(() => onOpenChange(false));
|
||||
};
|
||||
|
||||
return (
|
||||
<DialogWrapper
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
type="custom"
|
||||
title={t("shutdown.title")}
|
||||
description={t("shutdown.description")}
|
||||
showFooter={false}
|
||||
>
|
||||
<div className="flex gap-2 p-4">
|
||||
<Input
|
||||
type="number"
|
||||
value={time}
|
||||
onChange={(e) => setTime(Number.parseInt(e.target.value))}
|
||||
suffix={t("unit.minute.plural")}
|
||||
/>
|
||||
<Button className="w-24" onClick={handleScheduledShutdown}>
|
||||
<ClockIcon size={16} />
|
||||
</Button>
|
||||
<Button className="w-24" name="now" onClick={handleImmediateShutdown}>
|
||||
<PowerIcon className="mr-2" size={16} />
|
||||
{t("button.now")}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
118
packages/web/src/components/Dialog/useDialog.ts
Normal file
118
packages/web/src/components/Dialog/useDialog.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import { type ReactNode, useCallback, useState } from "react";
|
||||
import type { useTranslation } from "react-i18next";
|
||||
|
||||
export type DialogType = "confirm" | "alert" | "info" | "custom";
|
||||
|
||||
export interface BaseDialogConfig {
|
||||
type: DialogType;
|
||||
title: string;
|
||||
description?: string;
|
||||
onConfirm?: () => void | Promise<void>;
|
||||
onCancel?: () => void;
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
export interface ConfirmDialogConfig extends BaseDialogConfig {
|
||||
type: "confirm";
|
||||
confirmText?: string;
|
||||
cancelText?: string;
|
||||
variant?: "default" | "destructive";
|
||||
icon?: ReactNode;
|
||||
confirmIcon?: ReactNode;
|
||||
onConfirm: () => void | Promise<void>;
|
||||
}
|
||||
|
||||
export interface AlertDialogConfig extends BaseDialogConfig {
|
||||
type: "alert";
|
||||
dismissText?: string;
|
||||
icon?: ReactNode;
|
||||
}
|
||||
|
||||
export interface InfoDialogConfig extends BaseDialogConfig {
|
||||
type: "info";
|
||||
dismissText?: string;
|
||||
icon?: ReactNode;
|
||||
}
|
||||
|
||||
export interface CustomDialogConfig extends BaseDialogConfig {
|
||||
type: "custom";
|
||||
icon?: React.ReactNode;
|
||||
content: ReactNode;
|
||||
footerActions?: ReactNode;
|
||||
}
|
||||
|
||||
export type DialogConfig =
|
||||
| ConfirmDialogConfig
|
||||
| AlertDialogConfig
|
||||
| InfoDialogConfig
|
||||
| CustomDialogConfig;
|
||||
|
||||
export interface DialogState {
|
||||
isOpen: boolean;
|
||||
config?: DialogConfig;
|
||||
}
|
||||
|
||||
export interface UseDialogReturn {
|
||||
isOpen: boolean;
|
||||
config?: DialogConfig;
|
||||
openDialog: (config: DialogConfig) => void;
|
||||
closeDialog: () => void;
|
||||
handleConfirm: () => void;
|
||||
handleCancel: () => void;
|
||||
}
|
||||
|
||||
export const useDialog = (initialState?: DialogState): UseDialogReturn => {
|
||||
const [state, setState] = useState<DialogState>(
|
||||
initialState ?? { isOpen: false },
|
||||
);
|
||||
|
||||
const openDialog = useCallback((config: DialogConfig) => {
|
||||
setState({ isOpen: true, config });
|
||||
}, []);
|
||||
|
||||
const closeDialog = useCallback(() => {
|
||||
state.config?.onClose?.();
|
||||
setState({ isOpen: false, config: undefined });
|
||||
}, [state.config]);
|
||||
|
||||
const handleConfirm = useCallback(async () => {
|
||||
if (state.config?.onConfirm) {
|
||||
await state.config.onConfirm();
|
||||
}
|
||||
closeDialog();
|
||||
}, [state.config, closeDialog]);
|
||||
|
||||
const handleCancel = useCallback(() => {
|
||||
state.config?.onCancel?.();
|
||||
closeDialog();
|
||||
}, [state.config, closeDialog]);
|
||||
|
||||
return {
|
||||
isOpen: state.isOpen,
|
||||
config: state.config,
|
||||
openDialog,
|
||||
closeDialog,
|
||||
handleConfirm,
|
||||
handleCancel,
|
||||
};
|
||||
};
|
||||
|
||||
export const getDefaultTexts = (
|
||||
type: DialogType,
|
||||
t: ReturnType<typeof useTranslation>["t"],
|
||||
) => {
|
||||
switch (type) {
|
||||
case "confirm":
|
||||
return {
|
||||
confirmText: t("button.confirm"),
|
||||
cancelText: t("button.cancel"),
|
||||
};
|
||||
case "alert":
|
||||
case "info":
|
||||
return {
|
||||
dismissText: t("button.dismiss"),
|
||||
};
|
||||
default:
|
||||
return {};
|
||||
}
|
||||
};
|
||||
@@ -32,7 +32,7 @@ interface AppState {
|
||||
setNodeNumDetails: (nodeNum: number) => void;
|
||||
}
|
||||
|
||||
export const useAppStore = create<AppState>()((set, get) => ({
|
||||
export const useAppStore = create<AppState>()((set, _get) => ({
|
||||
selectedDeviceId: 0,
|
||||
devices: [],
|
||||
currentPage: "messages",
|
||||
|
||||
Reference in New Issue
Block a user