feat: add reusable confirmation dialogs for destructive actions
This commit is contained in:
@@ -2,6 +2,7 @@
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<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="icon" href="%PUBLIC_URL%/favicon.svg" type="image/svg+xml" />
|
||||||
<link rel="alternate icon" href="%PUBLIC_URL%/favicon.ico" />
|
<link rel="alternate icon" href="%PUBLIC_URL%/favicon.ico" />
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import { Navigate, Route, Routes, useLocation, useNavigate, createBrowserRouter,
|
|||||||
|
|
||||||
import { getTheme } from "./theme";
|
import { getTheme } from "./theme";
|
||||||
import { ToastProvider } from "./toast";
|
import { ToastProvider } from "./toast";
|
||||||
|
import { ConfirmProvider } from "./confirm";
|
||||||
|
|
||||||
import JobTable, { JobTableColumns } from "./components/JobTable";
|
import JobTable, { JobTableColumns } from "./components/JobTable";
|
||||||
import AddJobModal from "./components/AddJobModal";
|
import AddJobModal from "./components/AddJobModal";
|
||||||
@@ -245,3 +246,6 @@ export default function App() {
|
|||||||
</ToastProvider>
|
</ToastProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
vider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -95,7 +95,7 @@ export default function Attachments({ jobId }: { jobId: number }) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const remove = async (a: AttachmentItem) => {
|
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 {
|
try {
|
||||||
await api.delete(`/attachments/${a.id}`);
|
await api.delete(`/attachments/${a.id}`);
|
||||||
toast("Deleted attachment.", "success");
|
toast("Deleted attachment.", "success");
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import DeleteOutlineIcon from "@mui/icons-material/DeleteOutline";
|
|||||||
import { api } from "../api";
|
import { api } from "../api";
|
||||||
import { useToast } from "../toast";
|
import { useToast } from "../toast";
|
||||||
import { CorrespondenceMessage, GmailMessageSummary, GmailStatus } from "../types";
|
import { CorrespondenceMessage, GmailMessageSummary, GmailStatus } from "../types";
|
||||||
|
import { useDialogActions } from "../dialogs";
|
||||||
|
|
||||||
function parseRawEmail(raw: string): {
|
function parseRawEmail(raw: string): {
|
||||||
subject?: string;
|
subject?: string;
|
||||||
@@ -73,6 +74,7 @@ function parseRawEmail(raw: string): {
|
|||||||
export default function Correspondence({ jobId }: { jobId: number }) {
|
export default function Correspondence({ jobId }: { jobId: number }) {
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
const { confirmAction } = useDialogActions();
|
||||||
const [messages, setMessages] = useState<CorrespondenceMessage[]>([]);
|
const [messages, setMessages] = useState<CorrespondenceMessage[]>([]);
|
||||||
const [from, setFrom] = useState<"Me" | "Company">("Me");
|
const [from, setFrom] = useState<"Me" | "Company">("Me");
|
||||||
const [text, setText] = useState("");
|
const [text, setText] = useState("");
|
||||||
@@ -233,9 +235,8 @@ export default function Correspondence({ jobId }: { jobId: number }) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
const deleteMessage = async (messageId: 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 {
|
try {
|
||||||
await api.delete(`/correspondence/${messageId}`);
|
await api.delete(`/correspondence/${messageId}`);
|
||||||
await load();
|
await load();
|
||||||
@@ -527,8 +528,3 @@ export default function Correspondence({ jobId }: { jobId: number }) {
|
|||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
tions>
|
|
||||||
</Dialog>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ type UserDto = {
|
|||||||
|
|
||||||
export default function UserManagementCard() {
|
export default function UserManagementCard() {
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
const { confirmAction } = useDialogActions();
|
||||||
const token = getAuthToken();
|
const token = getAuthToken();
|
||||||
|
|
||||||
const [supported, setSupported] = useState<boolean | null>(null);
|
const [supported, setSupported] = useState<boolean | null>(null);
|
||||||
@@ -150,7 +151,7 @@ export default function UserManagementCard() {
|
|||||||
variant="outlined"
|
variant="outlined"
|
||||||
color="error"
|
color="error"
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
if (!window.confirm("Delete this user?")) return;
|
if (!(await confirmAction("Delete this user?", { title: "Delete user", confirmLabel: "Delete", destructive: true }))) return;
|
||||||
try {
|
try {
|
||||||
await api.delete(`/users/${u.id}`);
|
await api.delete(`/users/${u.id}`);
|
||||||
toast("User deleted.", "info");
|
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 {
|
import { useConfirm } from "./confirm";
|
||||||
return window.confirm(message);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function promptForValue(message: string, defaultValue = ""): string | null {
|
export function useDialogActions() {
|
||||||
return window.prompt(message, defaultValue);
|
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 { api } from "../api";
|
||||||
import { useToast } from "../toast";
|
import { useToast } from "../toast";
|
||||||
|
import { useDialogActions } from "../dialogs";
|
||||||
|
|
||||||
type UserDto = {
|
type UserDto = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -98,7 +99,7 @@ export default function AdminUsersPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const remove = async (u: UserDto) => {
|
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 {
|
try {
|
||||||
await api.delete(`/users/${u.id}`);
|
await api.delete(`/users/${u.id}`);
|
||||||
toast("User deleted.", "info");
|
toast("User deleted.", "info");
|
||||||
|
|||||||
Reference in New Issue
Block a user