diff --git a/__tests__/components/RejectReasonDialog.test.tsx b/__tests__/components/RejectReasonDialog.test.tsx new file mode 100644 index 0000000..b04b659 --- /dev/null +++ b/__tests__/components/RejectReasonDialog.test.tsx @@ -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( + , + ); + // "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( + , + ); + fireEvent.press(getByText("Cancel")); + expect(onCancel).toHaveBeenCalled(); + }); +}); diff --git a/__tests__/hooks/useApprovals.test.tsx b/__tests__/hooks/useApprovals.test.tsx new file mode 100644 index 0000000..82572c5 --- /dev/null +++ b/__tests__/hooks/useApprovals.test.tsx @@ -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 {children}; +} + +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"); + }); +}); diff --git a/app/(tabs)/more.tsx b/app/(tabs)/more.tsx index 168fc9f..9e2411f 100644 --- a/app/(tabs)/more.tsx +++ b/app/(tabs)/more.tsx @@ -7,6 +7,7 @@ import { LogOut, ChevronRight, Workflow, + Inbox, } from "lucide-react-native"; import { useRouter } from "expo-router"; import { authClient } from "~/lib/auth-client"; @@ -114,6 +115,11 @@ export default function MoreScreen() { {/* Automation */} + } + label="Approvals" + onPress={() => router.push("/approvals")} + /> } label="Flows" diff --git a/app/approvals/index.tsx b/app/approvals/index.tsx new file mode 100644 index 0000000..3195619 --- /dev/null +++ b/app/approvals/index.tsx @@ -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 ( + + + + {req.process_name ?? t("approvals.title")} + + {req.current_step ? {req.current_step} : null} + + {req.object_name && req.record_id ? ( + + {req.object_name} · {req.record_id} + + ) : null} + {req.submitter_comment ? ( + {req.submitter_comment} + ) : null} + {req.created_at ? ( + {formatDateTime(req.created_at)} + ) : null} + + + + {busy ? ( + + ) : ( + + )} + + {t("approvals.approve")} + + + + + {t("approvals.reject")} + + + + ); +} + +/** + * 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(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 ( + + 0 ? t("approvals.pendingCount", { count }) : undefined} + /> + {isLoading ? ( + + ) : error ? ( + void refetch()} + actionLoading={isRefetching} + /> + ) : count === 0 ? ( + + ) : ( + + {requests!.map((req) => ( + void handleApprove(req)} + onReject={() => setRejectTarget(req)} + /> + ))} + + )} + + setRejectTarget(null)} + onReject={(reason) => void handleReject(reason)} + /> + + ); +} diff --git a/apps/server/objectstack.config.ts b/apps/server/objectstack.config.ts index 01144e7..06d618e 100644 --- a/apps/server/objectstack.config.ts +++ b/apps/server/objectstack.config.ts @@ -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({ @@ -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; diff --git a/apps/server/package.json b/apps/server/package.json index d128a7f..1baf881 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -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", diff --git a/components/approvals/RejectReasonDialog.tsx b/components/approvals/RejectReasonDialog.tsx new file mode 100644 index 0000000..9790399 --- /dev/null +++ b/components/approvals/RejectReasonDialog.tsx @@ -0,0 +1,66 @@ +import React from "react"; +import { View } from "react-native"; +import { useTranslation } from "react-i18next"; +import { Dialog } from "~/components/ui/Dialog"; +import { Input } from "~/components/ui/Input"; +import { Button } from "~/components/ui/Button"; + +export interface RejectReasonDialogProps { + open: boolean; + isSubmitting?: boolean; + onCancel: () => void; + /** Confirm rejection with the entered (non-empty) reason. */ + onReject: (reason: string) => void; +} + +/** + * Collects the required rejection reason before rejecting an approval request. + * Cross-platform (Dialog/Modal), so it works on web and native. + */ +export function RejectReasonDialog({ + open, + isSubmitting = false, + onCancel, + onReject, +}: RejectReasonDialogProps) { + const { t } = useTranslation(); + const [reason, setReason] = React.useState(""); + + React.useEffect(() => { + if (open) setReason(""); + }, [open]); + + const trimmed = reason.trim(); + + return ( + (!o ? onCancel() : undefined)} + title={t("approvals.reject")} + description={t("approvals.rejectReason")} + > + + + + + + + ); +} diff --git a/hooks/useApprovals.ts b/hooks/useApprovals.ts new file mode 100644 index 0000000..d8af29b --- /dev/null +++ b/hooks/useApprovals.ts @@ -0,0 +1,113 @@ +import { useQuery, useQueryClient, type UseQueryResult } from "@tanstack/react-query"; +import { useClient } from "@objectstack/client-react"; +import { useCallback, useState } from "react"; + +/* ------------------------------------------------------------------ */ +/* Types */ +/* ------------------------------------------------------------------ */ + +/** A pending approval request row (`sys_approval_request`). */ +export interface ApprovalRequest { + id: string; + process_name?: string; + /** Business object + record the request is about. */ + object_name?: string; + record_id?: string; + submitter_id?: string; + submitter_comment?: string; + status?: string; + current_step?: string; + created_at?: string; +} + +export interface DecisionResult { + ok: boolean; + error?: string; +} + +/* ------------------------------------------------------------------ */ +/* Inbox */ +/* ------------------------------------------------------------------ */ + +const PENDING_KEY = ["approvals", "pending"] as const; + +/** + * The "my pending approvals" inbox — `sys_approval_request` rows awaiting a + * decision. Returns an empty list (not an error) when the approvals object + * isn't registered, so the screen renders an empty state. + */ +export function useApprovals(): UseQueryResult { + const client = useClient(); + return useQuery({ + queryKey: PENDING_KEY, + queryFn: async (): Promise => { + const res = await client.data.find("sys_approval_request", { + filter: ["status", "=", "pending"], + sort: "created_at desc", + top: 50, + }); + return res?.records ?? []; + }, + }); +} + +/* ------------------------------------------------------------------ */ +/* Decide */ +/* ------------------------------------------------------------------ */ + +/** + * Approve / reject a pending request by recording the decision on the + * `sys_approval_request` row (`status` → `approved` / `rejected`), which drops + * it from the pending inbox. The reject reason is appended to the row's comment + * for an audit trail. The pending list is refreshed afterwards regardless of + * outcome. + * + * Note: this records the decision; resuming a flow that's blocked on the request + * is handled server-side by the approval/workflow service when present. + */ +export function useDecideApproval(): { + approve: (req: ApprovalRequest, comment?: string) => Promise; + reject: (req: ApprovalRequest, reason: string) => Promise; + pendingId: string | null; +} { + const client = useClient(); + const queryClient = useQueryClient(); + const [pendingId, setPendingId] = useState(null); + + const decide = useCallback( + async ( + req: ApprovalRequest, + status: "approved" | "rejected", + note?: string, + ): Promise => { + setPendingId(req.id); + try { + const patch: Record = { status }; + if (note) { + patch.submitter_comment = req.submitter_comment + ? `${req.submitter_comment}\n— ${note}` + : note; + } + await client.data.update("sys_approval_request", req.id, patch); + return { ok: true }; + } catch (err) { + return { ok: false, error: err instanceof Error ? err.message : "Decision failed" }; + } finally { + setPendingId(null); + void queryClient.invalidateQueries({ queryKey: PENDING_KEY }); + } + }, + [client, queryClient], + ); + + const approve = useCallback( + (req: ApprovalRequest, comment?: string) => decide(req, "approved", comment), + [decide], + ); + const reject = useCallback( + (req: ApprovalRequest, reason: string) => decide(req, "rejected", reason), + [decide], + ); + + return { approve, reject, pendingId }; +} diff --git a/locales/ar.json b/locales/ar.json index 4a0fd8d..eb526b1 100644 --- a/locales/ar.json +++ b/locales/ar.json @@ -83,6 +83,21 @@ "steps": "الخطوات", "runLabel": "تشغيل" }, + "approvals": { + "title": "الموافقات", + "pendingCount": "{{count}} قيد الانتظار", + "empty": "لا توجد موافقات معلقة", + "emptyHint": "لقد أنجزت كل شيء.", + "loadError": "تعذّر تحميل الموافقات", + "approve": "موافقة", + "reject": "رفض", + "approveConfirm": "الموافقة على \"{{name}}\"؟", + "approved": "تمت الموافقة.", + "rejected": "تم الرفض.", + "decisionFailed": "فشلت العملية.", + "rejectReason": "يرجى تقديم سبب الرفض.", + "rejectReasonPlaceholder": "السبب…" + }, "actions": { "title": "إجراءات", "moreActions": "المزيد من الإجراءات", diff --git a/locales/en.json b/locales/en.json index f2cfbda..1940815 100644 --- a/locales/en.json +++ b/locales/en.json @@ -79,6 +79,21 @@ "steps": "Steps", "runLabel": "Run" }, + "approvals": { + "title": "Approvals", + "pendingCount": "{{count}} pending", + "empty": "No pending approvals", + "emptyHint": "You're all caught up.", + "loadError": "Couldn't load approvals", + "approve": "Approve", + "reject": "Reject", + "approveConfirm": "Approve \"{{name}}\"?", + "approved": "Approved.", + "rejected": "Rejected.", + "decisionFailed": "Decision failed.", + "rejectReason": "Provide a reason for rejection.", + "rejectReasonPlaceholder": "Reason…" + }, "actions": { "title": "Actions", "moreActions": "More actions", diff --git a/locales/zh.json b/locales/zh.json index 75eada4..389192e 100644 --- a/locales/zh.json +++ b/locales/zh.json @@ -78,6 +78,21 @@ "steps": "步骤", "runLabel": "运行" }, + "approvals": { + "title": "审批", + "pendingCount": "{{count}} 项待审批", + "empty": "暂无待审批", + "emptyHint": "你已全部处理完。", + "loadError": "加载审批失败", + "approve": "批准", + "reject": "驳回", + "approveConfirm": "批准“{{name}}”?", + "approved": "已批准。", + "rejected": "已驳回。", + "decisionFailed": "操作失败。", + "rejectReason": "请填写驳回理由。", + "rejectReasonPlaceholder": "理由…" + }, "actions": { "title": "操作", "moreActions": "更多操作", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5d4cb10..36fd654 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -307,6 +307,9 @@ importers: '@objectstack/objectql': specifier: ^7.5.0 version: 7.5.0(ai@6.0.193(zod@4.4.3)) + '@objectstack/plugin-approvals': + specifier: ^7.5.0 + version: 7.5.0(ai@6.0.193(zod@4.4.3)) '@objectstack/plugin-trigger-record-change': specifier: ^7.5.0 version: 7.5.0(ai@6.0.193(zod@4.4.3))