diff --git a/job-tracker-ui/src/App.tsx b/job-tracker-ui/src/App.tsx index 766ec90..b062b51 100644 --- a/job-tracker-ui/src/App.tsx +++ b/job-tracker-ui/src/App.tsx @@ -21,6 +21,7 @@ import { Navigate, Route, Routes, useLocation, useNavigate, createBrowserRouter, import { getTheme } from "./theme"; import { ToastProvider } from "./toast"; import { ConfirmProvider } from "./confirm"; +import { PromptProvider } from "./prompt"; import JobTable, { JobTableColumns } from "./components/JobTable"; import AddJobModal from "./components/AddJobModal"; @@ -237,12 +238,16 @@ export default function App() { return ( - - - - - - + + + + + + + + + + ); } diff --git a/job-tracker-ui/src/components/JobDetailsDialog.tsx b/job-tracker-ui/src/components/JobDetailsDialog.tsx index e635dfb..cb58b45 100644 --- a/job-tracker-ui/src/components/JobDetailsDialog.tsx +++ b/job-tracker-ui/src/components/JobDetailsDialog.tsx @@ -194,7 +194,7 @@ export default function JobDetailsDialog({ open, jobId, onClose }: Props) { disabled={refreshingAi} onClick={async () => { if (!jobId) return; - if (!window.confirm("Overwrite the current summary and skills with a freshly generated version?")) return; + if (!(await confirmAction("Overwrite the current summary and skills with a freshly generated version?", { title: "Refresh AI summary", confirmLabel: "Refresh" }))) return; setRefreshingAi(true); try { const res = await api.post(`/jobapplications/${jobId}/refresh-ai`); diff --git a/job-tracker-ui/src/components/JobTable.tsx b/job-tracker-ui/src/components/JobTable.tsx index bdfe023..9e58b1b 100644 --- a/job-tracker-ui/src/components/JobTable.tsx +++ b/job-tracker-ui/src/components/JobTable.tsx @@ -123,6 +123,7 @@ function statusTone(status: string): string { export default function JobTable({ refreshToken, pageSize, onPageSizeChange, columns, onColumnsChange, mode = "jobs" }: Props) { const theme = useTheme(); const { toast } = useToast(); + const { confirmAction } = useDialogActions(); const location = useLocation(); const navigate = useNavigate(); const [jobs, setJobs] = useState([]); @@ -400,3 +401,8 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col ); } +s); setStatusAnchor(null); setStatusJobId(null); }}>Set {s})} + + + ); +} diff --git a/job-tracker-ui/src/confirm.test.tsx b/job-tracker-ui/src/confirm.test.tsx new file mode 100644 index 0000000..d40aaca --- /dev/null +++ b/job-tracker-ui/src/confirm.test.tsx @@ -0,0 +1,26 @@ +import React from 'react'; +import '@testing-library/jest-dom'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { ConfirmProvider, useConfirm } from './confirm'; + +function Demo() { + const { confirm } = useConfirm(); + return ( + + ); +} + +test('renders app-owned confirmation dialog', async () => { + render( + + + , + ); + + fireEvent.click(screen.getByRole('button', { name: /open confirm/i })); + + expect(await screen.findByText(/are you sure/i)).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /delete/i })).toBeInTheDocument(); +}); diff --git a/job-tracker-ui/src/dialogs.ts b/job-tracker-ui/src/dialogs.ts index 479ee2f..5b909de 100644 --- a/job-tracker-ui/src/dialogs.ts +++ b/job-tracker-ui/src/dialogs.ts @@ -1,7 +1,9 @@ import { useConfirm } from "./confirm"; +import { usePrompt } from "./prompt"; export function useDialogActions() { const { confirm } = useConfirm(); + const { prompt } = usePrompt(); return { confirmAction: (message: string, options?: { title?: string; confirmLabel?: string; cancelLabel?: string; destructive?: boolean }) => @@ -12,6 +14,13 @@ export function useDialogActions() { cancelLabel: options?.cancelLabel, destructive: options?.destructive, }), - promptForValue: async (_message: string, defaultValue = "") => window.prompt(_message, defaultValue), + promptForValue: (message: string, defaultValue = "", options?: { title?: string; confirmLabel?: string; cancelLabel?: string }) => + prompt({ + message, + defaultValue, + title: options?.title, + confirmLabel: options?.confirmLabel, + cancelLabel: options?.cancelLabel, + }), }; } diff --git a/job-tracker-ui/src/prompt.tsx b/job-tracker-ui/src/prompt.tsx new file mode 100644 index 0000000..c13683a --- /dev/null +++ b/job-tracker-ui/src/prompt.tsx @@ -0,0 +1,80 @@ +import React, { createContext, useCallback, useContext, useMemo, useState } from "react"; +import { Button, Dialog, DialogActions, DialogContent, DialogTitle, TextField, Typography } from "@mui/material"; + +type PromptOptions = { + title?: string; + message: string; + defaultValue?: string; + confirmLabel?: string; + cancelLabel?: string; +}; + +type PromptContextValue = { + prompt: (options: PromptOptions) => Promise; +}; + +type PromptState = PromptOptions & { + open: boolean; + resolver?: (value: string | null) => void; +}; + +const PromptContext = createContext(null); + +export function PromptProvider({ children }: { children: React.ReactNode }) { + const [state, setState] = useState({ + open: false, + title: "Enter value", + message: "", + defaultValue: "", + confirmLabel: "Save", + cancelLabel: "Cancel", + }); + const [value, setValue] = useState(""); + + const closeWith = useCallback((next: string | null) => { + setState((prev) => { + prev.resolver?.(next); + return { ...prev, open: false, resolver: undefined }; + }); + }, []); + + const prompt = useCallback((options: PromptOptions) => { + return new Promise((resolve) => { + setValue(options.defaultValue ?? ""); + setState({ + open: true, + title: options.title ?? "Enter value", + message: options.message, + defaultValue: options.defaultValue ?? "", + confirmLabel: options.confirmLabel ?? "Save", + cancelLabel: options.cancelLabel ?? "Cancel", + resolver: resolve, + }); + }); + }, []); + + const contextValue = useMemo(() => ({ prompt }), [prompt]); + + return ( + + {children} + closeWith(null)} fullWidth maxWidth="xs"> + {state.title} + + {state.message} + setValue(e.target.value)} /> + + + + + + + + ); +} + +export function usePrompt() { + const ctx = useContext(PromptContext); + if (!ctx) throw new Error("usePrompt must be used within PromptProvider"); + return ctx; +}