Dialog abstraction using DialogWrapper (#830)

* feat: add dialog abstraction system

* removed unneeded file

* fix formatting

* linting fixes
This commit is contained in:
Dan Ditomaso
2025-09-10 13:56:45 -04:00
committed by GitHub
parent 3b0406c5af
commit aababb8075
6 changed files with 327 additions and 123 deletions

View File

@@ -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}
/>
);
};

View 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>
);
};

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View 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 {};
}
};

View File

@@ -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",