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 { getTheme } from "./theme";
|
||||||
import { ToastProvider } from "./toast";
|
import { ToastProvider } from "./toast";
|
||||||
import { ConfirmProvider } from "./confirm";
|
import { ConfirmProvider } from "./confirm";
|
||||||
|
import { PromptProvider } from "./prompt";
|
||||||
|
|
||||||
import JobTable, { JobTableColumns } from "./components/JobTable";
|
import JobTable, { JobTableColumns } from "./components/JobTable";
|
||||||
import AddJobModal from "./components/AddJobModal";
|
import AddJobModal from "./components/AddJobModal";
|
||||||
@@ -237,12 +238,16 @@ export default function App() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<ToastProvider>
|
<ToastProvider>
|
||||||
<CssVarsProvider key={`${effectiveMode}:${accentColor}`} theme={theme as any} defaultMode={effectiveMode} disableTransitionOnChange>
|
<ConfirmProvider>
|
||||||
<CssBaseline enableColorScheme />
|
<PromptProvider>
|
||||||
<I18nProvider>
|
<CssVarsProvider key={`${effectiveMode}:${accentColor}`} theme={theme as any} defaultMode={effectiveMode} disableTransitionOnChange>
|
||||||
<RouterProvider router={router} future={{ v7_startTransition: true }} />
|
<CssBaseline enableColorScheme />
|
||||||
</I18nProvider>
|
<I18nProvider>
|
||||||
</CssVarsProvider>
|
<RouterProvider router={router} future={{ v7_startTransition: true }} />
|
||||||
|
</I18nProvider>
|
||||||
|
</CssVarsProvider>
|
||||||
|
</PromptProvider>
|
||||||
|
</ConfirmProvider>
|
||||||
</ToastProvider>
|
</ToastProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -194,7 +194,7 @@ export default function JobDetailsDialog({ open, jobId, onClose }: Props) {
|
|||||||
disabled={refreshingAi}
|
disabled={refreshingAi}
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
if (!jobId) return;
|
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);
|
setRefreshingAi(true);
|
||||||
try {
|
try {
|
||||||
const res = await api.post<JobApplication>(`/jobapplications/${jobId}/refresh-ai`);
|
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) {
|
export default function JobTable({ refreshToken, pageSize, onPageSizeChange, columns, onColumnsChange, mode = "jobs" }: Props) {
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
const { confirmAction } = useDialogActions();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [jobs, setJobs] = useState<JobApplication[]>([]);
|
const [jobs, setJobs] = useState<JobApplication[]>([]);
|
||||||
@@ -400,3 +401,8 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
|
|||||||
</Box>
|
</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 { useConfirm } from "./confirm";
|
||||||
|
import { usePrompt } from "./prompt";
|
||||||
|
|
||||||
export function useDialogActions() {
|
export function useDialogActions() {
|
||||||
const { confirm } = useConfirm();
|
const { confirm } = useConfirm();
|
||||||
|
const { prompt } = usePrompt();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
confirmAction: (message: string, options?: { title?: string; confirmLabel?: string; cancelLabel?: string; destructive?: boolean }) =>
|
confirmAction: (message: string, options?: { title?: string; confirmLabel?: string; cancelLabel?: string; destructive?: boolean }) =>
|
||||||
@@ -12,6 +14,13 @@ export function useDialogActions() {
|
|||||||
cancelLabel: options?.cancelLabel,
|
cancelLabel: options?.cancelLabel,
|
||||||
destructive: options?.destructive,
|
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