From d040cc25266caf4eae5e1c06819acf8a645ee9b7 Mon Sep 17 00:00:00 2001 From: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Date: Tue, 2 Jun 2026 05:03:21 +0800 Subject: [PATCH] feat(ui): cross-platform confirm dialog; replace Alert.alert everywhere MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Alert.alert renders nothing on React Native Web — its confirm buttons never fire — so every confirmation (delete, sign out, uninstall, state transition, action confirmText) was a silent no-op in the web build, and notification alerts (upload/account/required-field errors) never showed. - Add ConfirmProvider + useConfirm(): a promise-based confirm backed by the Dialog/Modal primitives, so it works identically on web and native. Wired into the root layout. - Replace all confirm-style Alert.alert with useConfirm: record delete (detail + list), sign out (more + profile), package uninstall, state transition, and the object-action confirmText prompt in useRecordActions. - Replace notification-style Alert.alert with toasts: delete/upload failures, account success/error, and the action required-field message. Verified in-browser against a local 7.5.0 server: advancing crm_opportunity now shows a real "更新状态" confirm dialog, and confirming applies the transition (stage proposal → negotiation) and re-highlights the diagram — previously impossible on web. Adds useConfirm tests; typecheck + lint clean; full suite green apart from the pre-existing snapshots. Co-Authored-By: Claude Opus 4.8 --- __tests__/components/ConfirmDialog.test.tsx | 68 +++++++++++++ app/(app)/[appName]/[objectName]/[id].tsx | 82 +++++++--------- app/(app)/[appName]/[objectName]/index.tsx | 38 ++++---- app/(app)/packages.tsx | 35 +++---- app/(tabs)/more.tsx | 21 ++-- app/(tabs)/profile.tsx | 21 ++-- app/_layout.tsx | 21 ++-- app/account.tsx | 8 +- components/renderers/fields/FileField.tsx | 8 +- components/ui/ConfirmDialog.tsx | 103 ++++++++++++++++++++ hooks/useRecordActions.tsx | 18 ++-- 11 files changed, 300 insertions(+), 123 deletions(-) create mode 100644 __tests__/components/ConfirmDialog.test.tsx create mode 100644 components/ui/ConfirmDialog.tsx diff --git a/__tests__/components/ConfirmDialog.test.tsx b/__tests__/components/ConfirmDialog.test.tsx new file mode 100644 index 0000000..03a1256 --- /dev/null +++ b/__tests__/components/ConfirmDialog.test.tsx @@ -0,0 +1,68 @@ +/** + * Tests for the cross-platform ConfirmProvider / useConfirm — confirms that the + * dialog renders on demand and resolves true/false (unlike Alert.alert, which is + * a no-op on web). + */ +import React from "react"; +import { Text } from "react-native"; +import { render, fireEvent, waitFor, act } from "@testing-library/react-native"; + +jest.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (k: string) => (k === "common.confirm" ? "Confirm" : k === "common.cancel" ? "Cancel" : k), + }), +})); + +import { ConfirmProvider, useConfirm } from "~/components/ui/ConfirmDialog"; + +function Harness({ onResult }: { onResult: (v: boolean) => void }) { + const confirm = useConfirm(); + return ( + { + const ok = await confirm({ title: "Delete?", message: "Sure?", confirmLabel: "Delete" }); + onResult(ok); + }} + > + trigger + + ); +} + +function renderWithProvider(onResult: (v: boolean) => void) { + return render( + + + , + ); +} + +describe("useConfirm", () => { + it("resolves true when the confirm button is pressed", async () => { + const onResult = jest.fn(); + const { getByText } = renderWithProvider(onResult); + + act(() => { + fireEvent.press(getByText("trigger")); + }); + + // Dialog appears with our title + custom confirm label. + await waitFor(() => expect(getByText("Delete?")).toBeTruthy()); + fireEvent.press(getByText("Delete")); + + await waitFor(() => expect(onResult).toHaveBeenCalledWith(true)); + }); + + it("resolves false when cancelled", async () => { + const onResult = jest.fn(); + const { getByText } = renderWithProvider(onResult); + + act(() => { + fireEvent.press(getByText("trigger")); + }); + await waitFor(() => expect(getByText("Sure?")).toBeTruthy()); + fireEvent.press(getByText("Cancel")); + + await waitFor(() => expect(onResult).toHaveBeenCalledWith(false)); + }); +}); diff --git a/app/(app)/[appName]/[objectName]/[id].tsx b/app/(app)/[appName]/[objectName]/[id].tsx index e11b9a3..a1ef7e8 100644 --- a/app/(app)/[appName]/[objectName]/[id].tsx +++ b/app/(app)/[appName]/[objectName]/[id].tsx @@ -1,5 +1,4 @@ import { SafeAreaView } from "react-native-safe-area-context"; -import { Alert } from "react-native"; import { useLocalSearchParams, useRouter } from "expo-router"; import { useClient, useQuery, useView } from "@objectstack/client-react"; import { useTranslation } from "react-i18next"; @@ -16,6 +15,7 @@ import { } from "~/hooks/useStateMachines"; import { RecordStateMachines } from "~/components/workflow/RecordStateMachines"; import { useToast } from "~/components/ui/Toast"; +import { useConfirm } from "~/components/ui/ConfirmDialog"; import { isActionVisible } from "~/lib/record-actions"; import { renderRecordTitle } from "~/lib/record-title"; @@ -28,6 +28,8 @@ export default function ObjectDetailScreen() { const client = useClient(); const router = useRouter(); const { t } = useTranslation(); + const { toastSuccess, toastError } = useToast(); + const confirm = useConfirm(); const { data: viewData } = useView(objectName!, "form"); const { meta, fields } = useObjectMeta(objectName); @@ -95,23 +97,21 @@ export default function ObjectDetailScreen() { currentIndex >= 0 ? `${currentIndex + 1} of ${recordIds.length}` : undefined; /* ---- Delete handler ---- */ - const handleDelete = useCallback(() => { - Alert.alert(t("records.deleteRecord"), t("records.deleteConfirm"), [ - { text: t("common.cancel"), style: "cancel" }, - { - text: t("common.delete"), - style: "destructive", - onPress: async () => { - try { - await client.data.delete(objectName!, id!); - router.back(); - } catch { - Alert.alert(t("common.error"), t("records.deleteFailed")); - } - }, - }, - ]); - }, [client, objectName, id, router, t]); + const handleDelete = useCallback(async () => { + const ok = await confirm({ + title: t("records.deleteRecord"), + message: t("records.deleteConfirm"), + confirmLabel: t("common.delete"), + destructive: true, + }); + if (!ok) return; + try { + await client.data.delete(objectName!, id!); + router.back(); + } catch { + toastError(t("records.deleteFailed")); + } + }, [confirm, client, objectName, id, router, t, toastError]); /* ---- Object actions (record_header inline, record_more overflow) ---- */ const allActions = useMemo( @@ -140,40 +140,32 @@ export default function ObjectDetailScreen() { /* ---- Lifecycle / state machine diagram(s) ---- */ const stateMachines = useRecordStateMachines(meta, fields, record); - const { toastSuccess, toastError } = useToast(); const [pendingEvent, setPendingEvent] = useState(null); const handleTransition = useCallback( - (machine: RecordStateMachine, transition: SMTransition) => { + async (machine: RecordStateMachine, transition: SMTransition) => { if (!machine.field || !objectName || !id) return; const toLabel = machine.states.find((s) => s.name === transition.to)?.label ?? transition.to; - Alert.alert( - t("workflow.updateStatus"), - t("workflow.moveToConfirm", { state: toLabel }), - [ - { text: t("common.cancel"), style: "cancel" }, - { - text: t("common.confirm"), - onPress: async () => { - setPendingEvent(`${machine.key}:${transition.event}`); - try { - await client.data.update(objectName, id, { - [machine.field!]: transition.to, - }); - await fetchRecord(); - toastSuccess(t("workflow.statusUpdated")); - } catch { - toastError(t("workflow.statusUpdateFailed")); - } finally { - setPendingEvent(null); - } - }, - }, - ], - ); + const ok = await confirm({ + title: t("workflow.updateStatus"), + message: t("workflow.moveToConfirm", { state: toLabel }), + }); + if (!ok) return; + setPendingEvent(`${machine.key}:${transition.event}`); + try { + await client.data.update(objectName, id, { + [machine.field]: transition.to, + }); + await fetchRecord(); + toastSuccess(t("workflow.statusUpdated")); + } catch { + toastError(t("workflow.statusUpdateFailed")); + } finally { + setPendingEvent(null); + } }, - [client, objectName, id, t, fetchRecord, toastSuccess, toastError], + [confirm, client, objectName, id, t, fetchRecord, toastSuccess, toastError], ); return ( diff --git a/app/(app)/[appName]/[objectName]/index.tsx b/app/(app)/[appName]/[objectName]/index.tsx index 66ee2ee..867f056 100644 --- a/app/(app)/[appName]/[objectName]/index.tsx +++ b/app/(app)/[appName]/[objectName]/index.tsx @@ -1,5 +1,5 @@ import { SafeAreaView } from "react-native-safe-area-context"; -import { Alert, Pressable } from "react-native"; +import { Pressable } from "react-native"; import { useLocalSearchParams, useRouter } from "expo-router"; import { Plus } from "lucide-react-native"; import { useClient, useQuery, useView } from "@objectstack/client-react"; @@ -8,6 +8,8 @@ import { useCallback, useState } from "react"; import { ListViewRenderer } from "~/components/renderers"; import type { ListViewMeta } from "~/components/renderers"; import { ScreenHeader } from "~/components/common/ScreenHeader"; +import { useToast } from "~/components/ui/Toast"; +import { useConfirm } from "~/components/ui/ConfirmDialog"; import { useObjectMeta } from "~/hooks/useObjectMeta"; export default function ObjectListScreen() { @@ -18,6 +20,8 @@ export default function ObjectListScreen() { const client = useClient(); const router = useRouter(); const { t } = useTranslation(); + const { toastError } = useToast(); + const confirm = useConfirm(); const { data: viewData, isLoading: viewLoading } = useView(objectName!, "list"); const { meta, fields } = useObjectMeta(objectName); @@ -49,26 +53,24 @@ export default function ObjectListScreen() { ); const handleSwipeDelete = useCallback( - (record: Record) => { + async (record: Record) => { const id = (record.id ?? record._id) as string; const label = (record.name ?? record.label ?? record.title ?? id) as string; - Alert.alert(t("records.deleteRecord"), t("records.deleteConfirmNamed", { label }), [ - { text: t("common.cancel"), style: "cancel" }, - { - text: t("common.delete"), - style: "destructive", - onPress: async () => { - try { - await client.data.delete(objectName!, id); - refetch(); - } catch { - Alert.alert(t("common.error"), t("records.deleteFailed")); - } - }, - }, - ]); + const ok = await confirm({ + title: t("records.deleteRecord"), + message: t("records.deleteConfirmNamed", { label }), + confirmLabel: t("common.delete"), + destructive: true, + }); + if (!ok) return; + try { + await client.data.delete(objectName!, id); + refetch(); + } catch { + toastError(t("records.deleteFailed")); + } }, - [client, objectName, refetch, t], + [confirm, client, objectName, refetch, t, toastError], ); const handleFilterChange = useCallback((f: unknown) => { diff --git a/app/(app)/packages.tsx b/app/(app)/packages.tsx index 6b143c9..df63e0b 100644 --- a/app/(app)/packages.tsx +++ b/app/(app)/packages.tsx @@ -4,7 +4,6 @@ import { Text, ScrollView, TouchableOpacity, - Alert, } from "react-native"; import { SafeAreaView } from "react-native-safe-area-context"; import { Package, ToggleLeft, ToggleRight, Trash2 } from "lucide-react-native"; @@ -13,6 +12,7 @@ import { Card, CardHeader, CardTitle, CardContent } from "~/components/ui/Card"; import { ScreenHeader } from "~/components/common/ScreenHeader"; import { EmptyState } from "~/components/ui/EmptyState"; import { ListSkeleton } from "~/components/ui/ListSkeleton"; +import { useConfirm } from "~/components/ui/ConfirmDialog"; /** * Package management screen – list, enable, disable, uninstall packages. @@ -22,6 +22,7 @@ import { ListSkeleton } from "~/components/ui/ListSkeleton"; export default function PackagesScreen() { const { packages, isLoading, error, refetch, enable, disable, uninstall } = usePackageManagement(); + const confirm = useConfirm(); const handleToggle = async (id: string, enabled: boolean) => { try { @@ -35,25 +36,19 @@ export default function PackagesScreen() { } }; - const handleUninstall = (id: string, name: string) => { - Alert.alert( - "Uninstall Package", - `Are you sure you want to uninstall "${name}"?`, - [ - { text: "Cancel", style: "cancel" }, - { - text: "Uninstall", - style: "destructive", - onPress: async () => { - try { - await uninstall(id); - } catch { - // Error is already set in the hook - } - }, - }, - ], - ); + const handleUninstall = async (id: string, name: string) => { + const ok = await confirm({ + title: "Uninstall Package", + message: `Are you sure you want to uninstall "${name}"?`, + confirmLabel: "Uninstall", + destructive: true, + }); + if (!ok) return; + try { + await uninstall(id); + } catch { + // Error is already set in the hook + } }; return ( diff --git a/app/(tabs)/more.tsx b/app/(tabs)/more.tsx index 1f42d40..168fc9f 100644 --- a/app/(tabs)/more.tsx +++ b/app/(tabs)/more.tsx @@ -1,4 +1,4 @@ -import { View, Text, ScrollView, TouchableOpacity, Alert } from "react-native"; +import { View, Text, ScrollView, TouchableOpacity } from "react-native"; import { SafeAreaView } from "react-native-safe-area-context"; import { UserCircle, @@ -10,6 +10,8 @@ import { } from "lucide-react-native"; import { useRouter } from "expo-router"; import { authClient } from "~/lib/auth-client"; +import { useToast } from "~/components/ui/Toast"; +import { useConfirm } from "~/components/ui/ConfirmDialog"; interface MenuItemProps { icon: React.ReactNode; @@ -51,21 +53,26 @@ function SectionHeader({ title }: { title: string }) { export default function MoreScreen() { const { data: session } = authClient.useSession(); const router = useRouter(); + const { toastError } = useToast(); + const confirm = useConfirm(); const performSignOut = async () => { try { await authClient.signOut(); router.replace("/(auth)/sign-in"); } catch { - Alert.alert("Error", "Failed to sign out. Please try again."); + toastError("Failed to sign out. Please try again."); } }; - const handleSignOut = () => { - Alert.alert("Sign Out", "Are you sure you want to sign out?", [ - { text: "Cancel", style: "cancel" }, - { text: "Sign Out", style: "destructive", onPress: () => void performSignOut() }, - ]); + const handleSignOut = async () => { + const ok = await confirm({ + title: "Sign Out", + message: "Are you sure you want to sign out?", + confirmLabel: "Sign Out", + destructive: true, + }); + if (ok) void performSignOut(); }; return ( diff --git a/app/(tabs)/profile.tsx b/app/(tabs)/profile.tsx index 522a61c..ba420b2 100644 --- a/app/(tabs)/profile.tsx +++ b/app/(tabs)/profile.tsx @@ -1,28 +1,35 @@ -import { View, Text, ScrollView, Alert } from "react-native"; +import { View, Text, ScrollView } from "react-native"; import { SafeAreaView } from "react-native-safe-area-context"; import { UserCircle } from "lucide-react-native"; import { useRouter } from "expo-router"; import { Button } from "~/components/ui/Button"; import { authClient } from "~/lib/auth-client"; +import { useToast } from "~/components/ui/Toast"; +import { useConfirm } from "~/components/ui/ConfirmDialog"; export default function ProfileScreen() { const { data: session } = authClient.useSession(); const router = useRouter(); + const { toastError } = useToast(); + const confirm = useConfirm(); const performSignOut = async () => { try { await authClient.signOut(); router.replace("/(auth)/sign-in"); } catch { - Alert.alert("Error", "Failed to sign out. Please try again."); + toastError("Failed to sign out. Please try again."); } }; - const handleSignOut = () => { - Alert.alert("Sign Out", "Are you sure you want to sign out?", [ - { text: "Cancel", style: "cancel" }, - { text: "Sign Out", style: "destructive", onPress: () => void performSignOut() }, - ]); + const handleSignOut = async () => { + const ok = await confirm({ + title: "Sign Out", + message: "Are you sure you want to sign out?", + confirmLabel: "Sign Out", + destructive: true, + }); + if (ok) void performSignOut(); }; return ( diff --git a/app/_layout.tsx b/app/_layout.tsx index 0d30a7a..d61913a 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -13,6 +13,7 @@ import { createObjectStackClient } from "~/lib/objectstack"; import { useServerStore } from "~/stores/server-store"; import { usePushNotifications } from "~/hooks/usePushNotifications"; import { ToastProvider } from "~/components/ui/Toast"; +import { ConfirmProvider } from "~/components/ui/ConfirmDialog"; const queryClient = new QueryClient(); @@ -98,15 +99,17 @@ export default function RootLayout() { - - - - - - - - - + + + + + + + + + + + diff --git a/app/account.tsx b/app/account.tsx index 3174001..be51239 100644 --- a/app/account.tsx +++ b/app/account.tsx @@ -1,5 +1,5 @@ import { useState } from "react"; -import { View, Text, ScrollView, Alert } from "react-native"; +import { View, Text, ScrollView } from "react-native"; import { SafeAreaView } from "react-native-safe-area-context"; import { authClient } from "~/lib/auth-client"; import { useAccount } from "~/hooks/useAccount"; @@ -7,6 +7,7 @@ import { useTwoFactor } from "~/hooks/useTwoFactor"; import { Input } from "~/components/ui/Input"; import { Button } from "~/components/ui/Button"; import { ScreenHeader } from "~/components/common/ScreenHeader"; +import { useToast } from "~/components/ui/Toast"; function Field({ label, @@ -58,9 +59,10 @@ export default function AccountScreen() { const [tfBackupCodes, setTfBackupCodes] = useState([]); const [tfCode, setTfCode] = useState(""); - const notify = (msg: string) => Alert.alert("Account", msg); + const { toastSuccess, toastError } = useToast(); + const notify = (msg: string) => toastSuccess(msg); const fail = (e: unknown) => - Alert.alert("Account", e instanceof Error ? e.message : "Something went wrong"); + toastError(e instanceof Error ? e.message : "Something went wrong"); const onSaveName = async () => { if (!name.trim()) return; diff --git a/components/renderers/fields/FileField.tsx b/components/renderers/fields/FileField.tsx index 81d912b..5f8959f 100644 --- a/components/renderers/fields/FileField.tsx +++ b/components/renderers/fields/FileField.tsx @@ -1,5 +1,5 @@ import React, { useCallback, useState } from "react"; -import { View, Text, Pressable, ActivityIndicator, Alert } from "react-native"; +import { View, Text, Pressable, ActivityIndicator } from "react-native"; import { Camera, Upload, @@ -11,6 +11,7 @@ import { } from "lucide-react-native"; import type { FieldDefinition } from "../types"; import type { FileUploadResult } from "~/hooks/useFileUpload"; +import { useToast } from "~/components/ui/Toast"; /* ------------------------------------------------------------------ */ /* Types */ @@ -80,6 +81,7 @@ export function FileField({ error, }: FileFieldProps) { const [isUploading, setIsUploading] = useState(false); + const { toastError } = useToast(); const fileInfo = resolveFileInfo(value); const handleUpload = useCallback( @@ -92,12 +94,12 @@ export function FileField({ onChange?.(result); } } catch { - Alert.alert("Upload Failed", "Could not upload the file. Please try again."); + toastError("Could not upload the file. Please try again."); } finally { setIsUploading(false); } }, - [onChange], + [onChange, toastError], ); /* ---- Read-only ---- */ diff --git a/components/ui/ConfirmDialog.tsx b/components/ui/ConfirmDialog.tsx new file mode 100644 index 0000000..b35ed12 --- /dev/null +++ b/components/ui/ConfirmDialog.tsx @@ -0,0 +1,103 @@ +import React from "react"; +import { View } from "react-native"; +import { useTranslation } from "react-i18next"; +import { Dialog } from "./Dialog"; +import { Button } from "./Button"; + +/* ------------------------------------------------------------------ */ +/* Types */ +/* ------------------------------------------------------------------ */ + +export interface ConfirmOptions { + title: string; + /** Body text below the title. */ + message?: string; + /** Confirm button label. Defaults to `common.confirm`. */ + confirmLabel?: string; + /** Cancel button label. Defaults to `common.cancel`. */ + cancelLabel?: string; + /** Style the confirm button as destructive (e.g. delete). */ + destructive?: boolean; +} + +interface ConfirmContextValue { + /** Open a confirm dialog; resolves `true` if confirmed, `false` otherwise. */ + confirm: (options: ConfirmOptions) => Promise; +} + +const ConfirmContext = React.createContext(null); + +/* ------------------------------------------------------------------ */ +/* Provider */ +/* ------------------------------------------------------------------ */ + +interface PendingConfirm extends ConfirmOptions { + resolve: (value: boolean) => void; +} + +/** + * Cross-platform confirm dialog. Unlike `Alert.alert` (which renders nothing on + * React Native Web), this is backed by the `Dialog`/`Modal` primitives and + * works identically on web and native. Expose via {@link useConfirm}. + */ +export function ConfirmProvider({ children }: { children: React.ReactNode }) { + const { t } = useTranslation(); + const [pending, setPending] = React.useState(null); + + const confirm = React.useCallback( + (options: ConfirmOptions) => + new Promise((resolve) => { + setPending({ ...options, resolve }); + }), + [], + ); + + const settle = React.useCallback( + (value: boolean) => { + setPending((curr) => { + curr?.resolve(value); + return null; + }); + }, + [], + ); + + return ( + + {children} + { + if (!open) settle(false); + }} + title={pending?.title ?? ""} + description={pending?.message} + > + + + + + + + ); +} + +/* ------------------------------------------------------------------ */ +/* Hook */ +/* ------------------------------------------------------------------ */ + +export function useConfirm(): ConfirmContextValue["confirm"] { + const ctx = React.useContext(ConfirmContext); + if (!ctx) { + throw new Error("useConfirm must be used within a "); + } + return ctx.confirm; +} diff --git a/hooks/useRecordActions.tsx b/hooks/useRecordActions.tsx index 72888b6..eea4ad7 100644 --- a/hooks/useRecordActions.tsx +++ b/hooks/useRecordActions.tsx @@ -7,7 +7,7 @@ * per-button spinners, and `modals` JSX the screen must render once. */ import React from "react"; -import { Alert, Modal, ScrollView, Text, TextInput, View } from "react-native"; +import { Modal, ScrollView, Text, TextInput, View } from "react-native"; import { useRouter } from "expo-router"; import { useTranslation } from "react-i18next"; import type { ObjectStackClient } from "@objectstack/client"; @@ -17,6 +17,7 @@ import { Select } from "~/components/ui/Select"; import { Switch } from "~/components/ui/Switch"; import { DatePicker } from "~/components/ui/DatePicker"; import { useToast } from "~/components/ui/Toast"; +import { useConfirm } from "~/components/ui/ConfirmDialog"; import type { ActionMeta, ActionParamMeta } from "~/components/renderers/types"; import { runRecordAction, type ActionRunContext } from "~/lib/record-actions"; @@ -80,15 +81,10 @@ export function useRecordActions({ const [paramValues, setParamValues] = React.useState>({}); const [resultState, setResultState] = React.useState(null); + const showConfirm = useConfirm(); const confirm = React.useCallback( - (message: string) => - new Promise((resolve) => { - Alert.alert("", message, [ - { text: t("common.cancel"), style: "cancel", onPress: () => resolve(false) }, - { text: t("common.ok"), onPress: () => resolve(true) }, - ]); - }), - [t], + (message: string) => showConfirm({ title: t("common.confirm"), message }), + [showConfirm, t], ); const collectParams = React.useCallback( @@ -162,12 +158,12 @@ export function useRecordActions({ (p) => p.required && (paramValues[p.name] == null || paramValues[p.name] === ""), ); if (missing) { - Alert.alert("", t("actions.required", { field: missing.label })); + toastError(t("actions.required", { field: missing.label })); return; } setParamState(null); state.resolve(paramValues); - }, [paramState, paramValues, t]); + }, [paramState, paramValues, t, toastError]); const cancelParams = React.useCallback(() => { const state = paramState;