feat: add reusable prompt dialogs for frontend forms
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user