diff --git a/job-tracker-ui/public/index.html b/job-tracker-ui/public/index.html index ffb2312..2c49c04 100644 --- a/job-tracker-ui/public/index.html +++ b/job-tracker-ui/public/index.html @@ -2,6 +2,7 @@ + diff --git a/job-tracker-ui/src/App.tsx b/job-tracker-ui/src/App.tsx index 8367157..766ec90 100644 --- a/job-tracker-ui/src/App.tsx +++ b/job-tracker-ui/src/App.tsx @@ -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() { ); } +vider> + ); +} diff --git a/job-tracker-ui/src/components/Attachments.tsx b/job-tracker-ui/src/components/Attachments.tsx index 6325a12..118c4a7 100644 --- a/job-tracker-ui/src/components/Attachments.tsx +++ b/job-tracker-ui/src/components/Attachments.tsx @@ -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"); diff --git a/job-tracker-ui/src/components/Correspondence.tsx b/job-tracker-ui/src/components/Correspondence.tsx index d60ab65..a841a10 100644 --- a/job-tracker-ui/src/components/Correspondence.tsx +++ b/job-tracker-ui/src/components/Correspondence.tsx @@ -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([]); 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 }) { ); } -tions> - - - ); -} diff --git a/job-tracker-ui/src/components/UserManagementCard.tsx b/job-tracker-ui/src/components/UserManagementCard.tsx index fc4e8fd..673eb38 100644 --- a/job-tracker-ui/src/components/UserManagementCard.tsx +++ b/job-tracker-ui/src/components/UserManagementCard.tsx @@ -17,6 +17,7 @@ type UserDto = { export default function UserManagementCard() { const { toast } = useToast(); + const { confirmAction } = useDialogActions(); const token = getAuthToken(); const [supported, setSupported] = useState(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"); diff --git a/job-tracker-ui/src/confirm.tsx b/job-tracker-ui/src/confirm.tsx new file mode 100644 index 0000000..6d50f1a --- /dev/null +++ b/job-tracker-ui/src/confirm.tsx @@ -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; +}; + +type ConfirmState = ConfirmOptions & { + open: boolean; + resolver?: (value: boolean) => void; +}; + +const ConfirmContext = createContext(null); + +export function ConfirmProvider({ children }: { children: React.ReactNode }) { + const [state, setState] = useState({ + 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((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 ( + + {children} + closeWith(false)} fullWidth maxWidth="xs"> + {state.title} + + {state.message} + + + + + + + + ); +} + +export function useConfirm() { + const ctx = useContext(ConfirmContext); + if (!ctx) throw new Error("useConfirm must be used within ConfirmProvider"); + return ctx; +} diff --git a/job-tracker-ui/src/dialogs.ts b/job-tracker-ui/src/dialogs.ts index f91983c..479ee2f 100644 --- a/job-tracker-ui/src/dialogs.ts +++ b/job-tracker-ui/src/dialogs.ts @@ -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), + }; } diff --git a/job-tracker-ui/src/pages/AdminUsersPage.tsx b/job-tracker-ui/src/pages/AdminUsersPage.tsx index 4ba8771..eabcca7 100644 --- a/job-tracker-ui/src/pages/AdminUsersPage.tsx +++ b/job-tracker-ui/src/pages/AdminUsersPage.tsx @@ -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");