diff --git a/apps/web/src/features/resume/editor/write/rich-text-editor.test.tsx b/apps/web/src/features/resume/editor/write/rich-text-editor.test.tsx
index 4f1b0f6f7..d958fed45 100644
--- a/apps/web/src/features/resume/editor/write/rich-text-editor.test.tsx
+++ b/apps/web/src/features/resume/editor/write/rich-text-editor.test.tsx
@@ -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(
-
+
{}} />
-
+
,
);
diff --git a/apps/web/src/features/resume/editor/write/rich-text-editor.tsx b/apps/web/src/features/resume/editor/write/rich-text-editor.tsx
index 3bfbd9aac..7d45b9a8f 100644
--- a/apps/web/src/features/resume/editor/write/rich-text-editor.tsx
+++ b/apps/web/src/features/resume/editor/write/rich-text-editor.tsx
@@ -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 = {
diff --git a/apps/web/src/features/resume/editor/write/section-row.tsx b/apps/web/src/features/resume/editor/write/section-row.tsx
index 2a10c1a97..adabe064f 100644
--- a/apps/web/src/features/resume/editor/write/section-row.tsx
+++ b/apps/web/src/features/resume/editor/write/section-row.tsx
@@ -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";
diff --git a/apps/web/src/features/resume/editor/write/write-panel.test.tsx b/apps/web/src/features/resume/editor/write/write-panel.test.tsx
index 68d86a2a5..b907a84a0 100644
--- a/apps/web/src/features/resume/editor/write/write-panel.test.tsx
+++ b/apps/web/src/features/resume/editor/write/write-panel.test.tsx
@@ -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(
-
+
-
+
,
);
diff --git a/apps/web/src/features/resume/share/history-tab.tsx b/apps/web/src/features/resume/share/history-tab.tsx
index fa06eb267..48b82013a 100644
--- a/apps/web/src/features/resume/share/history-tab.tsx
+++ b/apps/web/src/features/resume/share/history-tab.tsx
@@ -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";
diff --git a/apps/web/src/features/resume/share/link-tab.test.tsx b/apps/web/src/features/resume/share/link-tab.test.tsx
index 8213964a1..27cabdb92 100644
--- a/apps/web/src/features/resume/share/link-tab.test.tsx
+++ b/apps/web/src/features/resume/share/link-tab.test.tsx
@@ -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()),
+ 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()),
@@ -65,9 +68,9 @@ function renderTab() {
render(
-
+
-
+
,
);
diff --git a/apps/web/src/features/settings/account/security.tsx b/apps/web/src/features/settings/account/security.tsx
index bac069eda..ee9f21116 100644
--- a/apps/web/src/features/settings/account/security.tsx
+++ b/apps/web/src/features/settings/account/security.tsx
@@ -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";
diff --git a/apps/web/src/hooks/use-prompt.test.tsx b/apps/web/src/hooks/use-confirm.prompt.test.tsx
similarity index 85%
rename from apps/web/src/hooks/use-prompt.test.tsx
rename to apps/web/src/hooks/use-confirm.prompt.test.tsx
index da7ab3ddd..2a97d4635 100644
--- a/apps/web/src/hooks/use-prompt.test.tsx
+++ b/apps/web/src/hooks/use-confirm.prompt.test.tsx
@@ -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) => {children};
+const wrapper = ({ children }: HookWrapperProps) => {children};
const clickButton = (re: RegExp) => {
const buttons = Array.from(document.body.querySelectorAll("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 /);
+ it("throws when used outside ConfirmDialogProvider", () => {
+ expect(() => renderHook(() => usePrompt())).toThrow(/must be used within a /);
});
it("returns a function when wrapped in provider", () => {
diff --git a/apps/web/src/hooks/use-confirm.test.tsx b/apps/web/src/hooks/use-confirm.test.tsx
index 06a9a8042..553b5a05a 100644
--- a/apps/web/src/hooks/use-confirm.test.tsx
+++ b/apps/web/src/hooks/use-confirm.test.tsx
@@ -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) => {children};
describe("useConfirm", () => {
it("throws when used outside ConfirmDialogProvider", () => {
- expect(() => renderHook(() => useConfirm())).toThrow(/useConfirm must be used within a /);
+ expect(() => renderHook(() => useConfirm())).toThrow(/must be used within a /);
});
it("returns a confirm function when wrapped in provider", () => {
diff --git a/apps/web/src/hooks/use-confirm.tsx b/apps/web/src/hooks/use-confirm.tsx
index 2059443b9..6244dfbaf 100644
--- a/apps/web/src/hooks/use-confirm.tsx
+++ b/apps/web/src/hooks/use-confirm.tsx
@@ -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;
+ /** 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;
-const ConfirmContext = React.createContext(null);
+const AskContext = React.createContext(null);
-export function ConfirmDialogProvider({ children }: ConfirmDialogProviderProps) {
- const [state, setState] = React.useState({
- 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({ open: false, title: "", withInput: false, resolve: null });
+ const [value, setValue] = React.useState("");
- const confirm = React.useCallback((title: string, options?: ConfirmOptions): Promise => {
- return new Promise((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(() => ({ 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 (
-
+
{children}
- !open && handleCancel()}>
+ !open && answer(null)}>
{state.title}
@@ -83,22 +63,43 @@ export function ConfirmDialogProvider({ children }: ConfirmDialogProviderProps)
+ {state.withInput && (
+ setValue(event.target.value)}
+ onKeyDown={(event) => {
+ if (event.key === "Enter") answer(value);
+ }}
+ />
+ )}
+
- {state.cancelText ?? "Cancel"}
- {state.confirmText ?? "Confirm"}
+ answer(null)}>{state.cancelText ?? t`Cancel`}
+ answer(state.withInput ? value : "")}>
+ {state.confirmText ?? t`Confirm`}
+
-
+
);
}
+function useAsk() {
+ const ask = React.use(AskContext);
+ if (!ask) throw new Error("useConfirm and usePrompt must be used within a .");
+ 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 .");
- }
+ const ask = useAsk();
+ return async (title: string, options?: Omit) =>
+ (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);
}
diff --git a/apps/web/src/hooks/use-prompt.tsx b/apps/web/src/hooks/use-prompt.tsx
deleted file mode 100644
index 26dedb323..000000000
--- a/apps/web/src/hooks/use-prompt.tsx
+++ /dev/null
@@ -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, "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;
-};
-
-type PromptDialogProviderProps = {
- children: React.ReactNode;
-};
-
-const PromptContext = React.createContext(null);
-
-export function PromptDialogProvider({ children }: PromptDialogProviderProps) {
- const inputRef = React.useRef(null);
-
- const [state, setState] = React.useState({
- 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 => {
- return new Promise((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) => {
- setState((prev) => ({ ...prev, value: e.target.value }));
- }, []);
-
- const handleKeyDown = React.useCallback(
- (e: React.KeyboardEvent) => {
- if (e.key === "Enter") handleConfirm();
- },
- [handleConfirm],
- );
-
- const contextValue = React.useMemo(() => ({ prompt }), [prompt]);
-
- return (
-
- {children}
-
- !open && handleCancel()}>
-
-
- {state.title}
-
- {state.description}
-
-
-
-
-
-
- {cancelText}
- {confirmText}
-
-
-
-
- );
-}
-
-export function usePrompt() {
- const context = React.use(PromptContext);
-
- if (!context) {
- throw new Error("usePrompt must be used within a .");
- }
-
- return context.prompt;
-}
diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx
index ae629d217..81b38e381 100644
--- a/apps/web/src/routes/__root.tsx
+++ b/apps/web/src/routes/__root.tsx
@@ -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() {
-
-
+
- {!isBuilder && }
-
-
-
+ {!isBuilder && }
+
+
+
- {import.meta.env.DEV && }
- {import.meta.env.DEV && (
- ,
- },
- {
- name: "TanStack Router",
- render: ,
- },
- ]}
- />
- )}
-
+ {import.meta.env.DEV && }
+ {import.meta.env.DEV && (
+ ,
+ },
+ {
+ name: "TanStack Router",
+ render: ,
+ },
+ ]}
+ />
+ )}
diff --git a/apps/web/src/routes/builder/letter/-components/letter-bar.tsx b/apps/web/src/routes/builder/letter/-components/letter-bar.tsx
index c098f105d..29fd7b1f7 100644
--- a/apps/web/src/routes/builder/letter/-components/letter-bar.tsx
+++ b/apps/web/src/routes/builder/letter/-components/letter-bar.tsx
@@ -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";