feat: add reusable confirmation dialogs for destructive actions

This commit is contained in:
cesnimda
2026-03-22 14:12:00 +01:00
parent 0863728eab
commit 758d2c6a0b
8 changed files with 107 additions and 15 deletions
+1
View File
@@ -2,6 +2,7 @@
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="%PUBLIC_URL%/favicon.svg" type="image/svg+xml" />
<link rel="alternate icon" href="%PUBLIC_URL%/favicon.ico" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
+4
View File
@@ -20,6 +20,7 @@ import { Navigate, Route, Routes, useLocation, useNavigate, createBrowserRouter,
import { getTheme } from "./theme";
import { ToastProvider } from "./toast";
import { ConfirmProvider } from "./confirm";
import JobTable, { JobTableColumns } from "./components/JobTable";
import AddJobModal from "./components/AddJobModal";
@@ -245,3 +246,6 @@ export default function App() {
</ToastProvider>
);
}
vider>
);
}
@@ -95,7 +95,7 @@ export default function Attachments({ jobId }: { jobId: number }) {
};
const remove = async (a: AttachmentItem) => {
if (!confirmAction(`Delete attachment "${a.fileName}"?`)) return;
if (!(await confirmAction(`Delete attachment "${a.fileName}"?`, { title: "Delete attachment", confirmLabel: "Delete", destructive: true }))) return;
try {
await api.delete(`/attachments/${a.id}`);
toast("Deleted attachment.", "success");
@@ -28,6 +28,7 @@ import DeleteOutlineIcon from "@mui/icons-material/DeleteOutline";
import { api } from "../api";
import { useToast } from "../toast";
import { CorrespondenceMessage, GmailMessageSummary, GmailStatus } from "../types";
import { useDialogActions } from "../dialogs";
function parseRawEmail(raw: string): {
subject?: string;
@@ -73,6 +74,7 @@ function parseRawEmail(raw: string): {
export default function Correspondence({ jobId }: { jobId: number }) {
const theme = useTheme();
const { toast } = useToast();
const { confirmAction } = useDialogActions();
const [messages, setMessages] = useState<CorrespondenceMessage[]>([]);
const [from, setFrom] = useState<"Me" | "Company">("Me");
const [text, setText] = useState("");
@@ -233,9 +235,8 @@ export default function Correspondence({ jobId }: { jobId: number }) {
}
};
const deleteMessage = async (messageId: number) => {
if (!confirmAction("Remove this correspondence message?")) return;
if (!(await confirmAction("Remove this correspondence message?", { title: "Delete message", confirmLabel: "Delete", destructive: true }))) return;
try {
await api.delete(`/correspondence/${messageId}`);
await load();
@@ -527,8 +528,3 @@ export default function Correspondence({ jobId }: { jobId: number }) {
</Box>
);
}
tions>
</Dialog>
</Box>
);
}
@@ -17,6 +17,7 @@ type UserDto = {
export default function UserManagementCard() {
const { toast } = useToast();
const { confirmAction } = useDialogActions();
const token = getAuthToken();
const [supported, setSupported] = useState<boolean | null>(null);
@@ -150,7 +151,7 @@ export default function UserManagementCard() {
variant="outlined"
color="error"
onClick={async () => {
if (!window.confirm("Delete this user?")) return;
if (!(await confirmAction("Delete this user?", { title: "Delete user", confirmLabel: "Delete", destructive: true }))) return;
try {
await api.delete(`/users/${u.id}`);
toast("User deleted.", "info");
+79
View File
@@ -0,0 +1,79 @@
import React, { createContext, useCallback, useContext, useMemo, useState } from "react";
import { Button, Dialog, DialogActions, DialogContent, DialogTitle, Typography } from "@mui/material";
type ConfirmOptions = {
title?: string;
message: string;
confirmLabel?: string;
cancelLabel?: string;
destructive?: boolean;
};
type ConfirmContextValue = {
confirm: (options: ConfirmOptions) => Promise<boolean>;
};
type ConfirmState = ConfirmOptions & {
open: boolean;
resolver?: (value: boolean) => void;
};
const ConfirmContext = createContext<ConfirmContextValue | null>(null);
export function ConfirmProvider({ children }: { children: React.ReactNode }) {
const [state, setState] = useState<ConfirmState>({
open: false,
message: "",
title: "Confirm action",
confirmLabel: "Confirm",
cancelLabel: "Cancel",
destructive: false,
});
const closeWith = useCallback((value: boolean) => {
setState((prev) => {
prev.resolver?.(value);
return { ...prev, open: false, resolver: undefined };
});
}, []);
const confirm = useCallback((options: ConfirmOptions) => {
return new Promise<boolean>((resolve) => {
setState({
open: true,
title: options.title ?? "Confirm action",
message: options.message,
confirmLabel: options.confirmLabel ?? "Confirm",
cancelLabel: options.cancelLabel ?? "Cancel",
destructive: options.destructive ?? false,
resolver: resolve,
});
});
}, []);
const value = useMemo(() => ({ confirm }), [confirm]);
return (
<ConfirmContext.Provider value={value}>
{children}
<Dialog open={state.open} onClose={() => closeWith(false)} fullWidth maxWidth="xs">
<DialogTitle>{state.title}</DialogTitle>
<DialogContent>
<Typography sx={{ color: "text.secondary" }}>{state.message}</Typography>
</DialogContent>
<DialogActions>
<Button onClick={() => closeWith(false)}>{state.cancelLabel}</Button>
<Button color={state.destructive ? "error" : "primary"} variant="contained" onClick={() => closeWith(true)}>
{state.confirmLabel}
</Button>
</DialogActions>
</Dialog>
</ConfirmContext.Provider>
);
}
export function useConfirm() {
const ctx = useContext(ConfirmContext);
if (!ctx) throw new Error("useConfirm must be used within ConfirmProvider");
return ctx;
}
+15 -5
View File
@@ -1,7 +1,17 @@
export function confirmAction(message: string): boolean {
return window.confirm(message);
}
import { useConfirm } from "./confirm";
export function promptForValue(message: string, defaultValue = ""): string | null {
return window.prompt(message, defaultValue);
export function useDialogActions() {
const { confirm } = useConfirm();
return {
confirmAction: (message: string, options?: { title?: string; confirmLabel?: string; cancelLabel?: string; destructive?: boolean }) =>
confirm({
message,
title: options?.title,
confirmLabel: options?.confirmLabel,
cancelLabel: options?.cancelLabel,
destructive: options?.destructive,
}),
promptForValue: async (_message: string, defaultValue = "") => window.prompt(_message, defaultValue),
};
}
+2 -1
View File
@@ -18,6 +18,7 @@ import {
import { api } from "../api";
import { useToast } from "../toast";
import { useDialogActions } from "../dialogs";
type UserDto = {
id: string;
@@ -98,7 +99,7 @@ export default function AdminUsersPage() {
};
const remove = async (u: UserDto) => {
if (!confirmAction(`Delete user ${u.email || u.userName || u.id}?`)) return;
if (!(await confirmAction(`Delete user ${u.email || u.userName || u.id}?`, { title: "Delete user", confirmLabel: "Delete", destructive: true }))) return;
try {
await api.delete(`/users/${u.id}`);
toast("User deleted.", "info");