feat: add reusable prompt dialogs for frontend forms

This commit is contained in:
cesnimda
2026-03-22 14:14:29 +01:00
parent 758d2c6a0b
commit abac48847c
6 changed files with 134 additions and 8 deletions
+5
View File
@@ -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 (
<ToastProvider>
<ConfirmProvider>
<PromptProvider>
<CssVarsProvider key={`${effectiveMode}:${accentColor}`} theme={theme as any} defaultMode={effectiveMode} disableTransitionOnChange>
<CssBaseline enableColorScheme />
<I18nProvider>
<RouterProvider router={router} future={{ v7_startTransition: true }} />
</I18nProvider>
</CssVarsProvider>
</PromptProvider>
</ConfirmProvider>
</ToastProvider>
);
}
@@ -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<JobApplication>(`/jobapplications/${jobId}/refresh-ai`);
@@ -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<JobApplication[]>([]);
@@ -400,3 +401,8 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
</Box>
);
}
s); setStatusAnchor(null); setStatusJobId(null); }}>Set {s}</MenuItem>)}
</Menu>
</Box>
);
}
+26
View File
@@ -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 (
<button onClick={() => void confirm({ title: 'Delete job', message: 'Are you sure?', confirmLabel: 'Delete', destructive: true })}>
Open confirm
</button>
);
}
test('renders app-owned confirmation dialog', async () => {
render(
<ConfirmProvider>
<Demo />
</ConfirmProvider>,
);
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();
});
+10 -1
View File
@@ -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,
}),
};
}
+80
View File
@@ -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<string | null>;
};
type PromptState = PromptOptions & {
open: boolean;
resolver?: (value: string | null) => void;
};
const PromptContext = createContext<PromptContextValue | null>(null);
export function PromptProvider({ children }: { children: React.ReactNode }) {
const [state, setState] = useState<PromptState>({
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<string | null>((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 (
<PromptContext.Provider value={contextValue}>
{children}
<Dialog open={state.open} onClose={() => closeWith(null)} fullWidth maxWidth="xs">
<DialogTitle>{state.title}</DialogTitle>
<DialogContent>
<Typography sx={{ color: "text.secondary", mb: 1.5 }}>{state.message}</Typography>
<TextField autoFocus fullWidth value={value} onChange={(e) => setValue(e.target.value)} />
</DialogContent>
<DialogActions>
<Button onClick={() => closeWith(null)}>{state.cancelLabel}</Button>
<Button variant="contained" onClick={() => closeWith(value)}>{state.confirmLabel}</Button>
</DialogActions>
</Dialog>
</PromptContext.Provider>
);
}
export function usePrompt() {
const ctx = useContext(PromptContext);
if (!ctx) throw new Error("usePrompt must be used within PromptProvider");
return ctx;
}