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 { useMessages } from "@app/core/stores/index.ts";
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogClose,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
} from "@components/UI/Dialog.tsx";
|
|
||||||
import { useMessages } from "@core/stores";
|
|
||||||
import { AlertTriangleIcon } from "lucide-react";
|
import { AlertTriangleIcon } from "lucide-react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { DialogWrapper } from "../DialogWrapper.tsx";
|
||||||
|
|
||||||
export interface DeleteMessagesDialogProps {
|
export interface DeleteMessagesDialogProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@@ -22,40 +13,25 @@ export const DeleteMessagesDialog = ({
|
|||||||
onOpenChange,
|
onOpenChange,
|
||||||
}: DeleteMessagesDialogProps) => {
|
}: DeleteMessagesDialogProps) => {
|
||||||
const { t } = useTranslation("dialog");
|
const { t } = useTranslation("dialog");
|
||||||
const { deleteAllMessages } = useMessages();
|
const messageStore = useMessages();
|
||||||
const handleCloseDialog = () => {
|
|
||||||
|
const handleConfirm = () => {
|
||||||
|
messageStore.deleteAllMessages();
|
||||||
onOpenChange(false);
|
onOpenChange(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<DialogWrapper
|
||||||
<DialogContent>
|
open={open}
|
||||||
<DialogClose data-testid="dialog-close-button" />
|
onOpenChange={onOpenChange}
|
||||||
<DialogHeader>
|
type="confirm"
|
||||||
<DialogTitle className="flex items-center gap-2">
|
title={t("deleteMessages.title")}
|
||||||
<AlertTriangleIcon className="h-5 w-5 text-warning" />
|
description={t("deleteMessages.description")}
|
||||||
{t("deleteMessages.title")}
|
icon={<AlertTriangleIcon className="h-5 w-5 text-warning" />}
|
||||||
</DialogTitle>
|
variant="destructive"
|
||||||
<DialogDescription>
|
confirmText={t("button.clearMessages")}
|
||||||
{t("deleteMessages.description")}
|
cancelText={t("button.dismiss")}
|
||||||
</DialogDescription>
|
onConfirm={handleConfirm}
|
||||||
</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>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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 { Label } from "@components/UI/Label.tsx";
|
||||||
import { useAppStore, useDevice, useNodeDB } from "@core/stores";
|
import { useAppStore, useDevice, useNodeDB } from "@core/stores";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { DialogWrapper } from "./DialogWrapper.tsx";
|
||||||
|
|
||||||
export interface RemoveNodeDialogProps {
|
export interface RemoveNodeDialogProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@@ -26,35 +17,25 @@ export const RemoveNodeDialog = ({
|
|||||||
const { getNode, removeNode } = useNodeDB();
|
const { getNode, removeNode } = useNodeDB();
|
||||||
const { nodeNumToBeRemoved } = useAppStore();
|
const { nodeNumToBeRemoved } = useAppStore();
|
||||||
|
|
||||||
const onSubmit = () => {
|
const handleConfirm = () => {
|
||||||
connection?.removeNodeByNum(nodeNumToBeRemoved);
|
connection?.removeNodeByNum(nodeNumToBeRemoved);
|
||||||
removeNode(nodeNumToBeRemoved);
|
removeNode(nodeNumToBeRemoved);
|
||||||
onOpenChange(false);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<DialogWrapper
|
||||||
<DialogContent>
|
open={open}
|
||||||
<DialogClose />
|
onOpenChange={onOpenChange}
|
||||||
<DialogHeader>
|
type="confirm"
|
||||||
<DialogTitle>{t("removeNode.title")}</DialogTitle>
|
title={t("removeNode.title")}
|
||||||
<DialogDescription>{t("removeNode.description")}</DialogDescription>
|
description={t("removeNode.description")}
|
||||||
</DialogHeader>
|
variant="destructive"
|
||||||
<div className="gap-4">
|
confirmText={t("button.remove")}
|
||||||
<form onSubmit={onSubmit}>
|
onConfirm={handleConfirm}
|
||||||
<Label>{getNode(nodeNumToBeRemoved)?.user?.longName}</Label>
|
>
|
||||||
</form>
|
<div className="gap-4">
|
||||||
</div>
|
<Label>{getNode(nodeNumToBeRemoved)?.user?.longName}</Label>
|
||||||
<DialogFooter>
|
</div>
|
||||||
<Button
|
</DialogWrapper>
|
||||||
variant="destructive"
|
|
||||||
name="remove"
|
|
||||||
onClick={() => onSubmit()}
|
|
||||||
>
|
|
||||||
{t("button.remove")}
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,17 +1,10 @@
|
|||||||
import { Button } from "@components/UI/Button.tsx";
|
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 { Input } from "@components/UI/Input.tsx";
|
||||||
import { useDevice } from "@core/stores";
|
import { useDevice } from "@core/stores";
|
||||||
import { ClockIcon, PowerIcon } from "lucide-react";
|
import { ClockIcon, PowerIcon } from "lucide-react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { DialogWrapper } from "./DialogWrapper.tsx";
|
||||||
|
|
||||||
export interface ShutdownDialogProps {
|
export interface ShutdownDialogProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@@ -21,45 +14,40 @@ export interface ShutdownDialogProps {
|
|||||||
export const ShutdownDialog = ({ open, onOpenChange }: ShutdownDialogProps) => {
|
export const ShutdownDialog = ({ open, onOpenChange }: ShutdownDialogProps) => {
|
||||||
const { t } = useTranslation("dialog");
|
const { t } = useTranslation("dialog");
|
||||||
const { connection } = useDevice();
|
const { connection } = useDevice();
|
||||||
|
|
||||||
const [time, setTime] = useState<number>(5);
|
const [time, setTime] = useState<number>(5);
|
||||||
|
|
||||||
return (
|
const handleScheduledShutdown = () => {
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
connection?.shutdown(time * 60).then(() => onOpenChange(false));
|
||||||
<DialogContent>
|
};
|
||||||
<DialogClose />
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>{t("shutdown.title")}</DialogTitle>
|
|
||||||
<DialogDescription>{t("shutdown.description")}</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<div className="flex gap-2 p-4">
|
const handleImmediateShutdown = () => {
|
||||||
<Input
|
connection?.shutdown(2).then(() => onOpenChange(false));
|
||||||
type="number"
|
};
|
||||||
value={time}
|
|
||||||
onChange={(e) => setTime(Number.parseInt(e.target.value))}
|
return (
|
||||||
suffix={t("unit.minute.plural")}
|
<DialogWrapper
|
||||||
/>
|
open={open}
|
||||||
<Button
|
onOpenChange={onOpenChange}
|
||||||
className="w-24"
|
type="custom"
|
||||||
onClick={() => {
|
title={t("shutdown.title")}
|
||||||
connection?.shutdown(time * 60).then(() => onOpenChange(false));
|
description={t("shutdown.description")}
|
||||||
}}
|
showFooter={false}
|
||||||
>
|
>
|
||||||
<ClockIcon size={16} />
|
<div className="flex gap-2 p-4">
|
||||||
</Button>
|
<Input
|
||||||
<Button
|
type="number"
|
||||||
className="w-24"
|
value={time}
|
||||||
name="now"
|
onChange={(e) => setTime(Number.parseInt(e.target.value))}
|
||||||
onClick={() => {
|
suffix={t("unit.minute.plural")}
|
||||||
connection?.shutdown(2).then(() => () => onOpenChange(false));
|
/>
|
||||||
}}
|
<Button className="w-24" onClick={handleScheduledShutdown}>
|
||||||
>
|
<ClockIcon size={16} />
|
||||||
<PowerIcon className="mr-2" size={16} />
|
</Button>
|
||||||
{t("button.now")}
|
<Button className="w-24" name="now" onClick={handleImmediateShutdown}>
|
||||||
</Button>
|
<PowerIcon className="mr-2" size={16} />
|
||||||
</div>
|
{t("button.now")}
|
||||||
</DialogContent>
|
</Button>
|
||||||
</Dialog>
|
</div>
|
||||||
|
</DialogWrapper>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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;
|
setNodeNumDetails: (nodeNum: number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useAppStore = create<AppState>()((set, get) => ({
|
export const useAppStore = create<AppState>()((set, _get) => ({
|
||||||
selectedDeviceId: 0,
|
selectedDeviceId: 0,
|
||||||
devices: [],
|
devices: [],
|
||||||
currentPage: "messages",
|
currentPage: "messages",
|
||||||
|
|||||||
Reference in New Issue
Block a user