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
48 changes: 48 additions & 0 deletions __tests__/components/RejectReasonDialog.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
/**
* Tests for RejectReasonDialog — requires a non-empty reason before rejecting.
*/
import React from "react";
import { render, fireEvent } from "@testing-library/react-native";

jest.mock("react-i18next", () => ({
initReactI18next: { type: "3rdParty", init: () => {} },
useTranslation: () => ({
t: (k: string) =>
({
"approvals.reject": "Reject",
"approvals.rejectReason": "Provide a reason for rejection.",
"approvals.rejectReasonPlaceholder": "Reason…",
"common.cancel": "Cancel",
})[k] ?? k,
}),
}));

import { RejectReasonDialog } from "~/components/approvals/RejectReasonDialog";

describe("RejectReasonDialog", () => {
it("disables Reject until a reason is entered, then submits the trimmed reason", () => {
const onReject = jest.fn();
const { getAllByText, getByPlaceholderText } = render(
<RejectReasonDialog open onCancel={jest.fn()} onReject={onReject} />,
);
// "Reject" is both the dialog title and the button label — the button is last.
const rejectButton = () => getAllByText("Reject").at(-1)!;

// Pressing Reject with an empty reason does nothing (button disabled).
fireEvent.press(rejectButton());
expect(onReject).not.toHaveBeenCalled();

fireEvent.changeText(getByPlaceholderText("Reason…"), " Over budget ");
fireEvent.press(rejectButton());
expect(onReject).toHaveBeenCalledWith("Over budget");
});

it("calls onCancel from the Cancel button", () => {
const onCancel = jest.fn();
const { getByText } = render(
<RejectReasonDialog open onCancel={onCancel} onReject={jest.fn()} />,
);
fireEvent.press(getByText("Cancel"));
expect(onCancel).toHaveBeenCalled();
});
});
89 changes: 89 additions & 0 deletions __tests__/hooks/useApprovals.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
/**
* Tests for useApprovals / useDecideApproval — pending-inbox fetch and the
* approve/reject decision (records status on the request row).
*/
import React from "react";
import { renderHook, waitFor, act } from "@testing-library/react-native";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";

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

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

function wrapper({ children }: { children: React.ReactNode }) {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return <QueryClientProvider client={client}>{children}</QueryClientProvider>;
}

const REQ: ApprovalRequest = {
id: "ar1",
process_name: "Large Deal Approval",
object_name: "crm_opportunity",
record_id: "opp1",
submitter_comment: "Please approve.",
status: "pending",
};

beforeEach(() => {
mockFind.mockReset();
mockUpdate.mockReset().mockResolvedValue({});
});

describe("useApprovals", () => {
it("queries pending requests and returns the rows", async () => {
mockFind.mockResolvedValue({ records: [REQ] });
const { result } = renderHook(() => useApprovals(), { wrapper });
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(mockFind).toHaveBeenCalledWith("sys_approval_request", {
filter: ["status", "=", "pending"],
sort: "created_at desc",
top: 50,
});
expect(result.current.data).toEqual([REQ]);
});

it("returns an empty list when there are no records", async () => {
mockFind.mockResolvedValue({});
const { result } = renderHook(() => useApprovals(), { wrapper });
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(result.current.data).toEqual([]);
});
});

describe("useDecideApproval", () => {
it("approve sets status=approved on the request row", async () => {
const { result } = renderHook(() => useDecideApproval(), { wrapper });
let res: { ok: boolean } = { ok: false };
await act(async () => {
res = await result.current.approve(REQ);
});
expect(res.ok).toBe(true);
expect(mockUpdate).toHaveBeenCalledWith("sys_approval_request", "ar1", { status: "approved" });
});

it("reject sets status=rejected and appends the reason to the comment", async () => {
const { result } = renderHook(() => useDecideApproval(), { wrapper });
await act(async () => {
await result.current.reject(REQ, "Over budget");
});
expect(mockUpdate).toHaveBeenCalledWith("sys_approval_request", "ar1", {
status: "rejected",
submitter_comment: "Please approve.\n— Over budget",
});
});

it("reports an error when the update fails", async () => {
mockUpdate.mockRejectedValue(new Error("nope"));
const { result } = renderHook(() => useDecideApproval(), { wrapper });
let res: { ok: boolean; error?: string } = { ok: true };
await act(async () => {
res = await result.current.approve(REQ);
});
expect(res.ok).toBe(false);
expect(res.error).toBe("nope");
});
});
6 changes: 6 additions & 0 deletions app/(tabs)/more.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
LogOut,
ChevronRight,
Workflow,
Inbox,
} from "lucide-react-native";
import { useRouter } from "expo-router";
import { authClient } from "~/lib/auth-client";
Expand Down Expand Up @@ -114,6 +115,11 @@ export default function MoreScreen() {

{/* Automation */}
<SectionHeader title="Automation" />
<MenuItem
icon={<Inbox size={20} color="#64748b" />}
label="Approvals"
onPress={() => router.push("/approvals")}
/>
<MenuItem
icon={<Workflow size={20} color="#64748b" />}
label="Flows"
Expand Down
167 changes: 167 additions & 0 deletions app/approvals/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
import { useState } from "react";
import { View, Text, ScrollView, Pressable, ActivityIndicator } from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
import { useTranslation } from "react-i18next";
import { Inbox, Check, X } 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 { useToast } from "~/components/ui/Toast";
import { useConfirm } from "~/components/ui/ConfirmDialog";
import { RejectReasonDialog } from "~/components/approvals/RejectReasonDialog";
import { formatDateTime } from "~/lib/formatting";
import {
useApprovals,
useDecideApproval,
type ApprovalRequest,
} from "~/hooks/useApprovals";

function ApprovalCard({
req,
busy,
onApprove,
onReject,
}: {
req: ApprovalRequest;
busy: boolean;
onApprove: () => void;
onReject: () => void;
}) {
const { t } = useTranslation();
return (
<View className="mb-3 rounded-xl border border-border bg-card p-4">
<View className="flex-row items-start justify-between gap-2">
<Text className="flex-1 text-base font-semibold text-foreground">
{req.process_name ?? t("approvals.title")}
</Text>
{req.current_step ? <Badge variant="secondary">{req.current_step}</Badge> : null}
</View>
{req.object_name && req.record_id ? (
<Text className="mt-0.5 text-xs text-muted-foreground">
{req.object_name} · {req.record_id}
</Text>
) : null}
{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 className="mt-3 flex-row gap-2">
<Pressable
onPress={onApprove}
disabled={busy}
accessibilityRole="button"
accessibilityLabel={`${t("approvals.approve")} ${req.process_name ?? req.id}`}
className={`flex-1 flex-row items-center justify-center gap-1.5 rounded-lg bg-primary px-3 py-2.5 ${
busy ? "opacity-50" : "active:opacity-80"
}`}
>
{busy ? (
<ActivityIndicator size="small" color="#ffffff" />
) : (
<Check size={16} color="#ffffff" />
)}
<Text className="text-sm font-medium text-primary-foreground">
{t("approvals.approve")}
</Text>
</Pressable>
<Pressable
onPress={onReject}
disabled={busy}
accessibilityRole="button"
accessibilityLabel={`${t("approvals.reject")} ${req.process_name ?? req.id}`}
className={`flex-1 flex-row items-center justify-center gap-1.5 rounded-lg border border-destructive px-3 py-2.5 ${
busy ? "opacity-50" : "active:bg-destructive/10"
}`}
>
<X size={16} color="#dc2626" />
<Text className="text-sm font-medium text-destructive">{t("approvals.reject")}</Text>
</Pressable>
</View>
</View>
);
}

/**
* Approvals inbox — lists the current user's pending approval requests and lets
* them approve (with an optional comment) or reject (with a required reason).
* Surfaces the previously-unused workflow approve/reject API.
*/
export default function ApprovalsScreen() {
const { t } = useTranslation();
const { toastSuccess, toastError } = useToast();
const confirm = useConfirm();
const { data: requests, isLoading, error, refetch, isRefetching } = useApprovals();
const { approve, reject, pendingId } = useDecideApproval();

const [rejectTarget, setRejectTarget] = useState<ApprovalRequest | null>(null);

const count = requests?.length ?? 0;

const handleApprove = async (req: ApprovalRequest) => {
const ok = await confirm({
title: t("approvals.approve"),
message: t("approvals.approveConfirm", { name: req.process_name ?? req.id }),
confirmLabel: t("approvals.approve"),
});
if (!ok) return;
const res = await approve(req);
if (res.ok) toastSuccess(t("approvals.approved"));
else toastError(res.error ?? t("approvals.decisionFailed"));
};

const handleReject = async (reason: string) => {
const req = rejectTarget;
setRejectTarget(null);
if (!req) return;
const res = await reject(req, reason);
if (res.ok) toastSuccess(t("approvals.rejected"));
else toastError(res.error ?? t("approvals.decisionFailed"));
};

return (
<SafeAreaView className="flex-1 bg-background" edges={["left", "right"]}>
<ScreenHeader
title={t("approvals.title")}
subtitle={count > 0 ? t("approvals.pendingCount", { count }) : undefined}
/>
{isLoading ? (
<ListSkeleton count={4} />
) : error ? (
<EmptyState
icon={Inbox}
variant="error"
title={t("approvals.loadError")}
description={error.message}
actionLabel={t("common.retry")}
onAction={() => void refetch()}
actionLoading={isRefetching}
/>
) : count === 0 ? (
<EmptyState icon={Inbox} title={t("approvals.empty")} description={t("approvals.emptyHint")} />
) : (
<ScrollView className="flex-1" contentContainerClassName="px-4 pt-4 pb-8">
{requests!.map((req) => (
<ApprovalCard
key={req.id}
req={req}
busy={pendingId === req.id}
onApprove={() => void handleApprove(req)}
onReject={() => setRejectTarget(req)}
/>
))}
</ScrollView>
)}

<RejectReasonDialog
open={!!rejectTarget}
isSubmitting={!!rejectTarget && pendingId === rejectTarget.id}
onCancel={() => setRejectTarget(null)}
onReject={(reason) => void handleReject(reason)}
/>
</SafeAreaView>
);
}
3 changes: 2 additions & 1 deletion apps/server/objectstack.config.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { defineStack, type ObjectStackDefinition } from '@objectstack/spec';
import { AutomationServicePlugin } from '@objectstack/service-automation';
import { ApprovalsServicePlugin } from '@objectstack/plugin-approvals';
import * as objects from './src/objects';

const stack: ObjectStackDefinition = defineStack({
Expand All @@ -17,7 +18,7 @@ const stack: ObjectStackDefinition = defineStack({
// Enable the automation engine so flows can be triggered + leave a run log
// (exposes /api/v1/automation/{name}/trigger and /runs). The plugin seeds the
// built-in node executors itself (ADR-0018).
plugins: [new AutomationServicePlugin()],
plugins: [new AutomationServicePlugin(), new ApprovalsServicePlugin()],
});

export default stack;
3 changes: 2 additions & 1 deletion apps/server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@
"@objectstack/driver-memory": "^7.5.0",
"@objectstack/service-automation": "^7.5.0",
"@objectstack/plugin-trigger-record-change": "^7.5.0",
"@objectstack/plugin-trigger-schedule": "^7.5.0"
"@objectstack/plugin-trigger-schedule": "^7.5.0",
"@objectstack/plugin-approvals": "^7.5.0"
},
"devDependencies": {
"@objectstack/cli": "^7.5.0",
Expand Down
Loading
Loading