Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 68 additions & 0 deletions __tests__/components/ConfirmDialog.test.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Text
onPress={async () => {
const ok = await confirm({ title: "Delete?", message: "Sure?", confirmLabel: "Delete" });
onResult(ok);
}}
>
trigger
</Text>
);
}

function renderWithProvider(onResult: (v: boolean) => void) {
return render(
<ConfirmProvider>
<Harness onResult={onResult} />
</ConfirmProvider>,
);
}

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));
});
});
82 changes: 37 additions & 45 deletions app/(app)/[appName]/[objectName]/[id].tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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";

Expand All @@ -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);

Expand Down Expand Up @@ -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<ActionMeta[]>(
Expand Down Expand Up @@ -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<string | null>(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 (
Expand Down
38 changes: 20 additions & 18 deletions app/(app)/[appName]/[objectName]/index.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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() {
Expand All @@ -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);
Expand Down Expand Up @@ -49,26 +53,24 @@ export default function ObjectListScreen() {
);

const handleSwipeDelete = useCallback(
(record: Record<string, unknown>) => {
async (record: Record<string, unknown>) => {
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) => {
Expand Down
35 changes: 15 additions & 20 deletions app/(app)/packages.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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.
Expand All @@ -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 {
Expand All @@ -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 (
Expand Down
21 changes: 14 additions & 7 deletions app/(tabs)/more.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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;
Expand Down Expand Up @@ -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 (
Expand Down
Loading
Loading