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";