refactor(web): answer confirmations and prompts with one dialog

This commit is contained in:
Amruth Pillai
2026-09-29 10:45:57 +02:00
parent 6e412c2f6b
commit bf3ca81c49
13 changed files with 107 additions and 252 deletions
@@ -4,7 +4,7 @@ import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import { i18n } from "@lingui/core";
import { I18nProvider } from "@lingui/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { PromptDialogProvider } from "@/hooks/use-prompt";
import { ConfirmDialogProvider } from "@/hooks/use-confirm";
import { RichTextEditor } from "./rich-text-editor";
const media = vi.hoisted(() => ({ mobile: false }));
@@ -30,9 +30,9 @@ const renderEditor = () =>
render(
<I18nProvider i18n={i18n}>
<QueryClientProvider client={new QueryClient()}>
<PromptDialogProvider>
<ConfirmDialogProvider>
<RichTextEditor label="Description" value="<p>Shipped the redesign.</p>" onChange={() => {}} />
</PromptDialogProvider>
</ConfirmDialogProvider>
</QueryClientProvider>
</I18nProvider>,
);
@@ -14,7 +14,7 @@ import { cn } from "@reactive-resume/utils/style";
import { hasUnsupportedTableMarkup, richTextExtensions } from "@/components/input/rich-text-extensions";
import { openAssistantFrom } from "@/features/assistant/open";
import { useHasUsableAiProvider } from "@/features/settings/integrations/hooks/use-has-usable-ai-provider";
import { usePrompt } from "@/hooks/use-prompt";
import { usePrompt } from "@/hooks/use-confirm";
import { ImprovePanel, lineAtCaret } from "./improve";
type ToolbarAction = {
@@ -27,7 +27,7 @@ import { toast } from "@reactive-resume/ui/components/toast";
import { cn } from "@reactive-resume/utils/style";
import { IconPicker } from "@/components/input/icon-picker";
import { useCurrentBuilderResumeSelector, useResumeStore, useUpdateResumeData } from "@/features/resume/builder/draft";
import { usePrompt } from "@/hooks/use-prompt";
import { usePrompt } from "@/hooks/use-confirm";
import { getSectionTitle } from "@/libs/resume/section";
import { pendingProposals } from "../proposals/proposals";
import { useEditorStore } from "../store";
@@ -9,7 +9,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { parseResumeData } from "@reactive-resume/schema/resume/data";
import { sampleResumeData } from "@reactive-resume/schema/resume/sample";
import { useResumeStore } from "@/features/resume/builder/draft";
import { PromptDialogProvider } from "@/hooks/use-prompt";
import { ConfirmDialogProvider } from "@/hooks/use-confirm";
import { useEditorStore } from "../store";
import { WritePanel } from "./write-panel";
@@ -66,9 +66,9 @@ function renderPanel(edit?: (data: ResumeData) => void) {
render(
<QueryClientProvider client={new QueryClient()}>
<I18nProvider i18n={i18n}>
<PromptDialogProvider>
<ConfirmDialogProvider>
<WritePanel />
</PromptDialogProvider>
</ConfirmDialogProvider>
</I18nProvider>
</QueryClientProvider>,
);
@@ -19,8 +19,7 @@ import { toast } from "@reactive-resume/ui/components/toast";
import { cn } from "@reactive-resume/utils/style";
import { savePendingChanges, useCurrentResume, useResumeStore } from "@/features/resume/builder/draft";
import { useEditorStore } from "@/features/resume/editor/store";
import { useConfirm } from "@/hooks/use-confirm";
import { usePrompt } from "@/hooks/use-prompt";
import { useConfirm, usePrompt } from "@/hooks/use-confirm";
import { getResumeErrorMessage } from "@/libs/error-message";
import { orpc } from "@/libs/orpc/client";
import { formatVersionMoment, formatVersionTime, getVersionDetail, getVersionTitle } from "./format";
@@ -6,7 +6,7 @@ import { i18n } from "@lingui/core";
import { I18nProvider } from "@lingui/react";
import { ORPCError } from "@orpc/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { PromptDialogProvider } from "@/hooks/use-prompt";
import { ConfirmDialogProvider } from "@/hooks/use-confirm";
import { LinkTab } from "./link-tab";
const mocks = vi.hoisted(() => ({
@@ -43,7 +43,10 @@ vi.mock("@/libs/orpc/client", () => ({
},
},
}));
vi.mock("@/hooks/use-confirm", () => ({ useConfirm: () => vi.fn() }));
vi.mock("@/hooks/use-confirm", async (importOriginal) => ({
...(await importOriginal<typeof import("@/hooks/use-confirm")>()),
useConfirm: () => vi.fn(),
}));
vi.mock("@reactive-resume/ui/components/toast", () => ({ toast: { add: vi.fn(), close: vi.fn() } }));
vi.mock("usehooks-ts", async (importOriginal) => ({
...(await importOriginal<typeof import("usehooks-ts")>()),
@@ -65,9 +68,9 @@ function renderTab() {
render(
<I18nProvider i18n={i18n}>
<QueryClientProvider client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}>
<PromptDialogProvider>
<ConfirmDialogProvider>
<LinkTab />
</PromptDialogProvider>
</ConfirmDialogProvider>
</QueryClientProvider>
</I18nProvider>,
);
@@ -7,7 +7,7 @@ import { Button, buttonVariants } from "@reactive-resume/ui/components/button";
import { Switch } from "@reactive-resume/ui/components/switch";
import { toast } from "@reactive-resume/ui/components/toast";
import { useDialogStore } from "@/dialogs/store";
import { usePrompt } from "@/hooks/use-prompt";
import { usePrompt } from "@/hooks/use-confirm";
import { authClient } from "@/libs/auth/client";
import { getReadableErrorMessage } from "@/libs/error-message";
import { SettingsRow, SettingsSection } from "../section";
@@ -3,7 +3,7 @@
import { act, renderHook } from "@testing-library/react";
import { beforeAll, describe, expect, it } from "vitest";
import { i18n } from "@lingui/core";
import { PromptDialogProvider, usePrompt } from "./use-prompt";
import { ConfirmDialogProvider, usePrompt } from "./use-confirm";
type HookWrapperProps = {
children: React.ReactNode;
@@ -13,7 +13,7 @@ beforeAll(() => {
i18n.loadAndActivate({ locale: "en", messages: {} });
});
const wrapper = ({ children }: HookWrapperProps) => <PromptDialogProvider>{children}</PromptDialogProvider>;
const wrapper = ({ children }: HookWrapperProps) => <ConfirmDialogProvider>{children}</ConfirmDialogProvider>;
const clickButton = (re: RegExp) => {
const buttons = Array.from(document.body.querySelectorAll<HTMLButtonElement>("button"));
@@ -21,8 +21,8 @@ const clickButton = (re: RegExp) => {
};
describe("usePrompt", () => {
it("throws when used outside PromptDialogProvider", () => {
expect(() => renderHook(() => usePrompt())).toThrow(/usePrompt must be used within a <PromptDialogProvider \/>/);
it("throws when used outside ConfirmDialogProvider", () => {
expect(() => renderHook(() => usePrompt())).toThrow(/must be used within a <ConfirmDialogProvider \/>/);
});
it("returns a function when wrapped in provider", () => {
+7 -2
View File
@@ -1,18 +1,23 @@
// @vitest-environment happy-dom
import { act, renderHook } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { beforeAll, describe, expect, it } from "vitest";
import { i18n } from "@lingui/core";
import { ConfirmDialogProvider, useConfirm } from "./use-confirm";
type HookWrapperProps = {
children: React.ReactNode;
};
beforeAll(() => {
i18n.loadAndActivate({ locale: "en", messages: {} });
});
const wrapper = ({ children }: HookWrapperProps) => <ConfirmDialogProvider>{children}</ConfirmDialogProvider>;
describe("useConfirm", () => {
it("throws when used outside ConfirmDialogProvider", () => {
expect(() => renderHook(() => useConfirm())).toThrow(/useConfirm must be used within a <ConfirmDialogProvider \/>/);
expect(() => renderHook(() => useConfirm())).toThrow(/must be used within a <ConfirmDialogProvider \/>/);
});
it("returns a confirm function when wrapped in provider", () => {
+57 -56
View File
@@ -1,3 +1,4 @@
import { t } from "@lingui/core/macro";
import * as React from "react";
import {
AlertDialog,
@@ -9,72 +10,51 @@ import {
AlertDialogHeader,
AlertDialogTitle,
} from "@reactive-resume/ui/components/alert-dialog";
import { Input } from "@reactive-resume/ui/components/input";
import { cn } from "@reactive-resume/utils/style";
interface ConfirmOptions {
type AskOptions = {
description?: string;
confirmText?: string;
cancelText?: string;
}
/** Prompts only: the text the field starts with. */
defaultValue?: string;
};
interface ConfirmState extends ConfirmOptions {
type AskState = AskOptions & {
open: boolean;
title: string;
resolve: ((value: boolean) => void) | null;
}
type ConfirmContextType = {
confirm: (title: string, options?: ConfirmOptions) => Promise<boolean>;
/** A prompt asks for text; a confirmation only for yes or no. */
withInput: boolean;
resolve: ((value: string | null) => void) | null;
};
type ConfirmDialogProviderProps = {
children: React.ReactNode;
};
type Ask = (title: string, options: AskOptions | undefined, withInput: boolean) => Promise<string | null>;
const ConfirmContext = React.createContext<ConfirmContextType | null>(null);
const AskContext = React.createContext<Ask | null>(null);
export function ConfirmDialogProvider({ children }: ConfirmDialogProviderProps) {
const [state, setState] = React.useState<ConfirmState>({
open: false,
resolve: null,
title: "",
description: undefined,
confirmText: undefined,
cancelText: undefined,
});
/** One dialog answers both `useConfirm` and `usePrompt`: a question, and a text field when it's a prompt. */
export function ConfirmDialogProvider({ children }: { children: React.ReactNode }) {
const [state, setState] = React.useState<AskState>({ open: false, title: "", withInput: false, resolve: null });
const [value, setValue] = React.useState("");
const confirm = React.useCallback((title: string, options?: ConfirmOptions): Promise<boolean> => {
return new Promise<boolean>((resolve) => {
setState({
open: true,
resolve,
title,
description: options?.description,
confirmText: options?.confirmText,
cancelText: options?.cancelText,
});
const ask: Ask = (title, options, withInput) =>
new Promise((resolve) => {
setValue(options?.defaultValue ?? "");
setState({ ...options, open: true, title, withInput, resolve });
});
}, []);
const handleConfirm = React.useCallback(() => {
if (state.resolve) state.resolve(true);
setState((prev) => ({ ...prev, open: false, resolve: null }));
}, [state.resolve]);
const handleCancel = React.useCallback(() => {
if (state.resolve) state.resolve(false);
setState((prev) => ({ ...prev, open: false, resolve: null }));
}, [state.resolve]);
const contextValue = React.useMemo<ConfirmContextType>(() => ({ confirm }), [confirm]);
// Cancelling answers null; confirming answers the text (empty for a confirmation). The state stays while closing.
const answer = (result: string | null) => {
state.resolve?.(result);
setState((previous) => ({ ...previous, open: false, resolve: null }));
};
return (
<ConfirmContext.Provider value={contextValue}>
<AskContext.Provider value={ask}>
{children}
<AlertDialog open={state.open} onOpenChange={(open) => !open && handleCancel()}>
<AlertDialog open={state.open} onOpenChange={(open) => !open && answer(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{state.title}</AlertDialogTitle>
@@ -83,22 +63,43 @@ export function ConfirmDialogProvider({ children }: ConfirmDialogProviderProps)
</AlertDialogDescription>
</AlertDialogHeader>
{state.withInput && (
<Input
value={value}
onChange={(event) => setValue(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") answer(value);
}}
/>
)}
<AlertDialogFooter>
<AlertDialogCancel onClick={handleCancel}>{state.cancelText ?? "Cancel"}</AlertDialogCancel>
<AlertDialogAction onClick={handleConfirm}>{state.confirmText ?? "Confirm"}</AlertDialogAction>
<AlertDialogCancel onClick={() => answer(null)}>{state.cancelText ?? t`Cancel`}</AlertDialogCancel>
<AlertDialogAction onClick={() => answer(state.withInput ? value : "")}>
{state.confirmText ?? t`Confirm`}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</ConfirmContext.Provider>
</AskContext.Provider>
);
}
function useAsk() {
const ask = React.use(AskContext);
if (!ask) throw new Error("useConfirm and usePrompt must be used within a <ConfirmDialogProvider />.");
return ask;
}
/** Resolves true when the user confirms. */
export function useConfirm() {
const context = React.use(ConfirmContext);
if (!context) {
throw new Error("useConfirm must be used within a <ConfirmDialogProvider />.");
}
const ask = useAsk();
return async (title: string, options?: Omit<AskOptions, "defaultValue">) =>
(await ask(title, options, false)) !== null;
}
return context.confirm;
/** Resolves with the text entered, or null when the user cancels. */
export function usePrompt() {
const ask = useAsk();
return (title: string, options?: AskOptions) => ask(title, options, true);
}
-150
View File
@@ -1,150 +0,0 @@
import { t } from "@lingui/core/macro";
import * as React from "react";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@reactive-resume/ui/components/alert-dialog";
import { Input } from "@reactive-resume/ui/components/input";
import { cn } from "@reactive-resume/utils/style";
type PromptOptions = {
description?: string;
defaultValue?: string;
confirmText?: string;
cancelText?: string;
inputProps?: Omit<React.ComponentProps<typeof Input>, "value" | "onChange" | "onKeyDown">;
};
type PromptState = PromptOptions & {
open: boolean;
title: string;
value: string;
resolve: ((value: string | null) => void) | null;
};
type PromptContextType = {
prompt: (title: string, options?: PromptOptions) => Promise<string | null>;
};
type PromptDialogProviderProps = {
children: React.ReactNode;
};
const PromptContext = React.createContext<PromptContextType | null>(null);
export function PromptDialogProvider({ children }: PromptDialogProviderProps) {
const inputRef = React.useRef<HTMLInputElement>(null);
const [state, setState] = React.useState<PromptState>({
open: false,
resolve: null,
title: "",
value: "",
description: undefined,
defaultValue: undefined,
confirmText: undefined,
cancelText: undefined,
inputProps: undefined,
});
const cancelText = state.cancelText ?? t`Cancel`;
const confirmText = state.confirmText ?? t`Confirm`;
React.useEffect(() => {
if (!state.open) return;
const timeoutId = window.setTimeout(() => {
if (!inputRef.current) return;
inputRef.current.focus();
}, 0);
return () => window.clearTimeout(timeoutId);
}, [state.open]);
const prompt = React.useCallback((title: string, options?: PromptOptions): Promise<string | null> => {
return new Promise<string | null>((resolve) => {
setState({
open: true,
resolve,
title,
value: options?.defaultValue ?? "",
description: options?.description,
defaultValue: options?.defaultValue,
confirmText: options?.confirmText,
cancelText: options?.cancelText,
inputProps: options?.inputProps,
});
});
}, []);
const handleConfirm = React.useCallback(() => {
if (state.resolve) state.resolve(state.value);
setState((prev) => ({ ...prev, open: false, resolve: null }));
}, [state.resolve, state.value]);
const handleCancel = React.useCallback(() => {
if (state.resolve) state.resolve(null);
setState((prev) => ({ ...prev, open: false, resolve: null }));
}, [state.resolve]);
const handleValueChange = React.useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
setState((prev) => ({ ...prev, value: e.target.value }));
}, []);
const handleKeyDown = React.useCallback(
(e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter") handleConfirm();
},
[handleConfirm],
);
const contextValue = React.useMemo<PromptContextType>(() => ({ prompt }), [prompt]);
return (
<PromptContext.Provider value={contextValue}>
{children}
<AlertDialog open={state.open} onOpenChange={(open) => !open && handleCancel()}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{state.title}</AlertDialogTitle>
<AlertDialogDescription className={cn(!state.description && "sr-only")}>
{state.description}
</AlertDialogDescription>
</AlertDialogHeader>
<Input
ref={inputRef}
value={state.value}
onKeyDown={handleKeyDown}
onChange={handleValueChange}
{...state.inputProps}
/>
<AlertDialogFooter>
<AlertDialogCancel onClick={handleCancel}>{cancelText}</AlertDialogCancel>
<AlertDialogAction onClick={handleConfirm}>{confirmText}</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</PromptContext.Provider>
);
}
export function usePrompt() {
const context = React.use(PromptContext);
if (!context) {
throw new Error("usePrompt must be used within a <PromptDialogProvider />.");
}
return context.prompt;
}
+21 -24
View File
@@ -26,7 +26,6 @@ import { DialogManager } from "@/dialogs/manager";
import { CommandPalette } from "@/features/command-palette";
import { ThemeProvider } from "@/features/theme/provider";
import { ConfirmDialogProvider } from "@/hooks/use-confirm";
import { PromptDialogProvider } from "@/hooks/use-prompt";
import { loadRootContext } from "@/libs/root-context";
type RouterContext = {
@@ -119,31 +118,29 @@ function RootComponent() {
<DirectionProvider direction={dir}>
<TooltipProvider>
<ConfirmDialogProvider>
<PromptDialogProvider>
<Outlet />
<Outlet />
{!isBuilder && <DonationToast />}
<DialogManager />
<CommandPalette />
<Toaster />
{!isBuilder && <DonationToast />}
<DialogManager />
<CommandPalette />
<Toaster />
{import.meta.env.DEV && <BreakpointIndicator />}
{import.meta.env.DEV && (
<TanStackDevtools
config={{ position: "bottom-left" }}
plugins={[
{
name: "TanStack Query",
render: <ReactQueryDevtoolsPanel />,
},
{
name: "TanStack Router",
render: <TanStackRouterDevtoolsPanel />,
},
]}
/>
)}
</PromptDialogProvider>
{import.meta.env.DEV && <BreakpointIndicator />}
{import.meta.env.DEV && (
<TanStackDevtools
config={{ position: "bottom-left" }}
plugins={[
{
name: "TanStack Query",
render: <ReactQueryDevtoolsPanel />,
},
{
name: "TanStack Router",
render: <TanStackRouterDevtoolsPanel />,
},
]}
/>
)}
</ConfirmDialogProvider>
</TooltipProvider>
</DirectionProvider>
@@ -25,7 +25,7 @@ import { createLetterFile, letterFileName } from "@/features/letters/export";
import { useLetterEditorStore } from "@/features/letters/store";
import { BackLink, DrawerControls } from "@/features/resume/editor/chrome";
import { useEditorStore } from "@/features/resume/editor/store";
import { usePrompt } from "@/hooks/use-prompt";
import { usePrompt } from "@/hooks/use-confirm";
import { getOrpcErrorMessage } from "@/libs/error-message";
import { client, orpc } from "@/libs/orpc/client";