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
49 changes: 49 additions & 0 deletions __tests__/components/ApprovalTargetCard.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
/**
* Tests for ApprovalTargetCard — renders the target record's title plus its
* populated business fields (skipping system/empty fields).
*/
import React from "react";
import { render } from "@testing-library/react-native";

jest.mock("react-i18next", () => ({
initReactI18next: { type: "3rdParty", init: () => {} },
useTranslation: () => ({ t: (k: string) => k }),
}));

import { ApprovalTargetCard } from "~/components/approvals/ApprovalTargetCard";
import type { FieldDefinition } from "~/components/renderers";
import type { ObjectMeta } from "~/hooks/useObjectMeta";

const meta = { name: "server_item", label: "Server Item" } as ObjectMeta;
const fields: FieldDefinition[] = [
{ name: "name", label: "Name", type: "text" } as FieldDefinition,
{ name: "description", label: "Description", type: "textarea" } as FieldDefinition,
{ name: "created_at", label: "Created At", type: "datetime" } as FieldDefinition,
];

describe("ApprovalTargetCard", () => {
it("renders the object label, record title, and populated fields", () => {
const record = {
id: "1",
name: "Wayne Enterprise License",
description: "Needs VP sign-off.",
created_at: "2026-06-02T00:00:00Z",
};
const { getByText, getAllByText, queryByText } = render(
<ApprovalTargetCard objectLabel="Server Item" meta={meta} fields={fields} record={record} />,
);
expect(getByText("Server Item")).toBeTruthy();
// Title resolves to the record's name (appears as title + field value).
expect(getAllByText("Wayne Enterprise License").length).toBeGreaterThan(0);
expect(getByText("Needs VP sign-off.")).toBeTruthy();
// System field is not previewed.
expect(queryByText("Created At")).toBeNull();
});

it("shows a placeholder when the record is missing", () => {
const { getByText } = render(
<ApprovalTargetCard objectLabel="Server Item" meta={meta} fields={fields} record={null} />,
);
expect(getByText("—")).toBeTruthy();
});
});
41 changes: 39 additions & 2 deletions __tests__/hooks/useApprovals.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,18 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";

const mockFind = jest.fn();
const mockUpdate = jest.fn();
const mockGet = jest.fn();
jest.mock("@objectstack/client-react", () => ({
useClient: () => ({ data: { find: mockFind, update: mockUpdate } }),
useClient: () => ({ data: { find: mockFind, update: mockUpdate, get: mockGet } }),
}));

import { useApprovals, useDecideApproval, type ApprovalRequest } from "~/hooks/useApprovals";
import {
useApprovals,
useApproval,
useApprovalTarget,
useDecideApproval,
type ApprovalRequest,
} from "~/hooks/useApprovals";

function wrapper({ children }: { children: React.ReactNode }) {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
Expand All @@ -31,6 +38,7 @@ const REQ: ApprovalRequest = {
beforeEach(() => {
mockFind.mockReset();
mockUpdate.mockReset().mockResolvedValue({});
mockGet.mockReset();
});

describe("useApprovals", () => {
Expand All @@ -54,6 +62,35 @@ describe("useApprovals", () => {
});
});

describe("useApproval", () => {
it("fetches a single request by id", async () => {
mockGet.mockResolvedValue({ record: REQ });
const { result } = renderHook(() => useApproval("ar1"), { wrapper });
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(mockGet).toHaveBeenCalledWith("sys_approval_request", "ar1");
expect(result.current.data).toEqual(REQ);
});
});

describe("useApprovalTarget", () => {
it("fetches the business record named by the request", async () => {
mockGet.mockResolvedValue({ record: { id: "opp1", name: "Acme Deal" } });
const { result } = renderHook(() => useApprovalTarget(REQ), { wrapper });
await waitFor(() => expect(result.current.record).toBeTruthy());
expect(mockGet).toHaveBeenCalledWith("crm_opportunity", "opp1");
expect(result.current.record).toEqual({ id: "opp1", name: "Acme Deal" });
});

it("does not fetch when the request has no target", async () => {
const { result } = renderHook(() => useApprovalTarget({ id: "x", status: "pending" }), {
wrapper,
});
await waitFor(() => expect(result.current.isLoading).toBe(false));
expect(mockGet).not.toHaveBeenCalled();
expect(result.current.record).toBeNull();
});
});

describe("useDecideApproval", () => {
it("approve sets status=approved on the request row", async () => {
const { result } = renderHook(() => useDecideApproval(), { wrapper });
Expand Down
109 changes: 109 additions & 0 deletions app/approvals/[id].tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import { View, Text, ScrollView } from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
import { useLocalSearchParams, useRouter } from "expo-router";
import { useTranslation } from "react-i18next";
import { Inbox } from "lucide-react-native";
import { ScreenHeader } from "~/components/common/ScreenHeader";
import { Badge } from "~/components/ui/Badge";
import { EmptyState } from "~/components/ui/EmptyState";
import { ListSkeleton } from "~/components/ui/ListSkeleton";
import { ApprovalActions } from "~/components/approvals/ApprovalActions";
import { ApprovalTargetCard } from "~/components/approvals/ApprovalTargetCard";
import { useObjectMeta } from "~/hooks/useObjectMeta";
import { useApproval, useApprovalTarget } from "~/hooks/useApprovals";
import { formatDateTime } from "~/lib/formatting";

function humanize(token: string): string {
return token.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
}

/**
* Approval detail — review the record under approval (title + key fields) and
* the request context before approving or rejecting.
*/
export default function ApprovalDetailScreen() {
const { id } = useLocalSearchParams<{ id: string }>();
const approvalId = Array.isArray(id) ? id[0] : id;
const router = useRouter();
const { t } = useTranslation();

const { data: req, isLoading, error } = useApproval(approvalId);
const { meta, fields } = useObjectMeta(req?.object_name);
const { record: target, isLoading: targetLoading } = useApprovalTarget(req);

if (isLoading) {
return (
<SafeAreaView className="flex-1 bg-background" edges={["left", "right"]}>
<ScreenHeader title={t("approvals.title")} backFallback="/approvals" />
<ListSkeleton count={4} />
</SafeAreaView>
);
}

if (error || !req) {
return (
<SafeAreaView className="flex-1 bg-background" edges={["left", "right"]}>
<ScreenHeader title={t("approvals.title")} backFallback="/approvals" />
<EmptyState
icon={Inbox}
variant={error ? "error" : "default"}
title={t("approvals.loadError")}
description={error?.message}
/>
</SafeAreaView>
);
}

const objectLabel = meta?.label ?? (req.object_name ? humanize(req.object_name) : "Record");
const isPending = (req.status ?? "pending") === "pending";

return (
<SafeAreaView className="flex-1 bg-background" edges={["left", "right"]}>
<ScreenHeader title={req.process_name ?? t("approvals.title")} backFallback="/approvals" />

<ScrollView className="flex-1" contentContainerClassName="px-4 pt-4 pb-8">
{/* Request context */}
<View className="mb-4 rounded-xl border border-border bg-card p-4">
<View className="flex-row flex-wrap items-center gap-2">
{req.current_step ? <Badge variant="secondary">{req.current_step}</Badge> : null}
<Badge variant={isPending ? "outline" : "default"}>
{req.status ? humanize(req.status) : humanize("pending")}
</Badge>
</View>
{req.submitter_comment ? (
<Text className="mt-2 text-sm text-foreground">{req.submitter_comment}</Text>
) : null}
{req.created_at ? (
<Text className="mt-2 text-xs text-muted-foreground">
{formatDateTime(req.created_at)}
</Text>
) : null}
</View>

{/* Record under review */}
{targetLoading ? (
<ListSkeleton count={3} />
) : (
<ApprovalTargetCard
objectLabel={objectLabel}
meta={meta}
fields={fields}
record={target}
/>
)}
</ScrollView>

{/* Decision bar */}
{isPending ? (
<View className="border-t border-border/40 px-4 py-3">
<ApprovalActions
req={req}
onDecided={() =>
router.canGoBack() ? router.back() : router.replace("/approvals")
}
/>
</View>
) : null}
</SafeAreaView>
);
}
Loading
Loading