feat: add reusable confirmation dialogs for destructive actions
This commit is contained in:
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user