Skip to content

Commit 78740b8

Browse files
Copilothotlong
andcommitted
feat: implement Phase 5B — AI agent integration, server-side i18n, and SDK hook aliases
- Add hooks/useAI.ts with useAI() hook wrapping client.ai.* (nlq, chat, suggest, insights) - Add hooks/useServerTranslations.ts wrapping client.i18n.* (getLocales, getTranslations, getFieldLabels) - Add hooks/useBatchMutation.ts re-exporting useBatchOperations with SDK naming - Add hooks/usePackages.ts re-exporting useAppDiscovery with SDK naming - Add hooks/useSavedViews.ts re-exporting useViewStorage with SDK naming - Update hooks/useObjectStack.ts barrel to export all new hooks - Add tests: useAI (9), useServerTranslations (7), useBatchOperations (7), useAnalyticsQuery (6), useAnalyticsMeta (4), useAppDiscovery (5) Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com>
1 parent e3fd5c7 commit 78740b8

12 files changed

Lines changed: 1369 additions & 0 deletions

__tests__/hooks/useAI.test.ts

Lines changed: 224 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,224 @@
1+
/**
2+
* Tests for useAI – validates AI integration hooks wrapping client.ai.*.
3+
*/
4+
import { renderHook, act, waitFor } from "@testing-library/react-native";
5+
6+
/* ---- Mock useClient from SDK ---- */
7+
const mockNlq = jest.fn();
8+
const mockChat = jest.fn();
9+
const mockSuggest = jest.fn();
10+
const mockInsights = jest.fn();
11+
12+
const mockClient = {
13+
ai: {
14+
nlq: mockNlq,
15+
chat: mockChat,
16+
suggest: mockSuggest,
17+
insights: mockInsights,
18+
},
19+
};
20+
21+
jest.mock("@objectstack/client-react", () => ({
22+
useClient: () => mockClient,
23+
}));
24+
25+
import { useAI } from "~/hooks/useAI";
26+
27+
beforeEach(() => {
28+
mockNlq.mockReset();
29+
mockChat.mockReset();
30+
mockSuggest.mockReset();
31+
mockInsights.mockReset();
32+
});
33+
34+
describe("useAI", () => {
35+
it("calls client.ai.nlq() with correct params", async () => {
36+
const nlqResponse = {
37+
query: { object: "tasks", select: ["*"] },
38+
explanation: "Find all tasks",
39+
confidence: 0.95,
40+
suggestions: ["Show open tasks"],
41+
};
42+
mockNlq.mockResolvedValue(nlqResponse);
43+
44+
const { result } = renderHook(() => useAI("tasks"));
45+
46+
let response: any;
47+
await act(async () => {
48+
response = await result.current.nlq("show all tasks");
49+
});
50+
51+
expect(mockNlq).toHaveBeenCalledWith({
52+
query: "show all tasks",
53+
object: "tasks",
54+
});
55+
expect(response).toEqual(nlqResponse);
56+
expect(result.current.isLoading).toBe(false);
57+
expect(result.current.error).toBeNull();
58+
});
59+
60+
it("nlq allows overriding the object context", async () => {
61+
mockNlq.mockResolvedValue({ query: {}, explanation: "", confidence: 0 });
62+
63+
const { result } = renderHook(() => useAI("tasks"));
64+
65+
await act(async () => {
66+
await result.current.nlq("show all orders", "orders");
67+
});
68+
69+
expect(mockNlq).toHaveBeenCalledWith({
70+
query: "show all orders",
71+
object: "orders",
72+
});
73+
});
74+
75+
it("handles nlq errors", async () => {
76+
mockNlq.mockRejectedValue(new Error("NLQ service unavailable"));
77+
78+
const { result } = renderHook(() => useAI("tasks"));
79+
80+
await act(async () => {
81+
await expect(result.current.nlq("bad query")).rejects.toThrow("NLQ service unavailable");
82+
});
83+
84+
expect(result.current.error?.message).toBe("NLQ service unavailable");
85+
expect(result.current.isLoading).toBe(false);
86+
});
87+
88+
it("calls client.ai.chat() and maintains conversation history", async () => {
89+
const chatResponse = {
90+
message: "Here are your open tasks.",
91+
conversationId: "conv-123",
92+
actions: [{ type: "navigate", label: "View Tasks" }],
93+
};
94+
mockChat.mockResolvedValue(chatResponse);
95+
96+
const { result } = renderHook(() => useAI("tasks"));
97+
98+
expect(result.current.messages).toHaveLength(0);
99+
expect(result.current.conversationId).toBeNull();
100+
101+
await act(async () => {
102+
await result.current.chat("Show me open tasks");
103+
});
104+
105+
expect(mockChat).toHaveBeenCalledWith({
106+
message: "Show me open tasks",
107+
});
108+
expect(result.current.conversationId).toBe("conv-123");
109+
expect(result.current.messages).toHaveLength(2);
110+
expect(result.current.messages[0]).toEqual({
111+
role: "user",
112+
content: "Show me open tasks",
113+
});
114+
expect(result.current.messages[1]).toEqual({
115+
role: "assistant",
116+
content: "Here are your open tasks.",
117+
actions: [{ type: "navigate", label: "View Tasks" }],
118+
});
119+
});
120+
121+
it("chat removes optimistic user message on error", async () => {
122+
mockChat.mockRejectedValue(new Error("Chat failed"));
123+
124+
const { result } = renderHook(() => useAI("tasks"));
125+
126+
await act(async () => {
127+
await expect(result.current.chat("hello")).rejects.toThrow("Chat failed");
128+
});
129+
130+
expect(result.current.messages).toHaveLength(0);
131+
expect(result.current.error?.message).toBe("Chat failed");
132+
});
133+
134+
it("clearConversation resets state", async () => {
135+
mockChat.mockResolvedValue({
136+
message: "Hello!",
137+
conversationId: "conv-456",
138+
});
139+
140+
const { result } = renderHook(() => useAI("tasks"));
141+
142+
await act(async () => {
143+
await result.current.chat("Hi");
144+
});
145+
146+
expect(result.current.messages).toHaveLength(2);
147+
expect(result.current.conversationId).toBe("conv-456");
148+
149+
act(() => {
150+
result.current.clearConversation();
151+
});
152+
153+
expect(result.current.messages).toHaveLength(0);
154+
expect(result.current.conversationId).toBeNull();
155+
expect(result.current.error).toBeNull();
156+
});
157+
158+
it("calls client.ai.suggest() with object context", async () => {
159+
const suggestResponse = {
160+
suggestions: [
161+
{ value: "High", label: "High Priority", confidence: 0.9, reason: "Most common" },
162+
{ value: "Medium", label: "Medium Priority", confidence: 0.7 },
163+
],
164+
};
165+
mockSuggest.mockResolvedValue(suggestResponse);
166+
167+
const { result } = renderHook(() => useAI("tasks"));
168+
169+
let response: any;
170+
await act(async () => {
171+
response = await result.current.suggest({ field: "priority", partial: "Hi" });
172+
});
173+
174+
expect(mockSuggest).toHaveBeenCalledWith({
175+
object: "tasks",
176+
field: "priority",
177+
partial: "Hi",
178+
});
179+
expect(response.suggestions).toHaveLength(2);
180+
});
181+
182+
it("calls client.ai.insights() with type and recordId", async () => {
183+
const insightsResponse = {
184+
insights: [
185+
{
186+
type: "summary",
187+
title: "Task Summary",
188+
description: "You have 5 overdue tasks",
189+
confidence: 0.85,
190+
},
191+
],
192+
};
193+
mockInsights.mockResolvedValue(insightsResponse);
194+
195+
const { result } = renderHook(() => useAI("tasks"));
196+
197+
let response: any;
198+
await act(async () => {
199+
response = await result.current.insights("summary", "rec-1");
200+
});
201+
202+
expect(mockInsights).toHaveBeenCalledWith({
203+
object: "tasks",
204+
type: "summary",
205+
recordId: "rec-1",
206+
});
207+
expect(response.insights).toHaveLength(1);
208+
expect(response.insights[0].title).toBe("Task Summary");
209+
});
210+
211+
it("calls client.ai.insights() without optional params", async () => {
212+
mockInsights.mockResolvedValue({ insights: [] });
213+
214+
const { result } = renderHook(() => useAI("tasks"));
215+
216+
await act(async () => {
217+
await result.current.insights();
218+
});
219+
220+
expect(mockInsights).toHaveBeenCalledWith({
221+
object: "tasks",
222+
});
223+
});
224+
});
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
/**
2+
* Tests for useAnalyticsMeta – validates analytics metadata fetching
3+
* via client.analytics.meta().
4+
*/
5+
import { renderHook, act, waitFor } from "@testing-library/react-native";
6+
7+
/* ---- Mock useClient from SDK ---- */
8+
const mockAnalyticsMeta = jest.fn();
9+
10+
const mockClient = {
11+
analytics: {
12+
meta: mockAnalyticsMeta,
13+
},
14+
};
15+
16+
jest.mock("@objectstack/client-react", () => ({
17+
useClient: () => mockClient,
18+
}));
19+
20+
import { useAnalyticsMeta } from "~/hooks/useAnalyticsMeta";
21+
22+
beforeEach(() => {
23+
mockAnalyticsMeta.mockReset();
24+
});
25+
26+
describe("useAnalyticsMeta", () => {
27+
it("fetches analytics metadata on mount", async () => {
28+
mockAnalyticsMeta.mockResolvedValue({
29+
metrics: [
30+
{ name: "tasks", label: "Tasks", type: "object", fields: ["status", "priority"], aggregates: ["count", "sum"] },
31+
{ name: "orders", label: "Orders", type: "object" },
32+
],
33+
});
34+
35+
const { result } = renderHook(() => useAnalyticsMeta());
36+
37+
await waitFor(() => {
38+
expect(result.current.isLoading).toBe(false);
39+
});
40+
41+
expect(result.current.metrics).toHaveLength(2);
42+
expect(result.current.metrics[0]).toEqual({
43+
name: "tasks",
44+
label: "Tasks",
45+
type: "object",
46+
fields: ["status", "priority"],
47+
aggregates: ["count", "sum"],
48+
});
49+
expect(result.current.error).toBeNull();
50+
});
51+
52+
it("handles array response format", async () => {
53+
mockAnalyticsMeta.mockResolvedValue([
54+
{ name: "users", label: "Users" },
55+
]);
56+
57+
const { result } = renderHook(() => useAnalyticsMeta());
58+
59+
await waitFor(() => {
60+
expect(result.current.isLoading).toBe(false);
61+
});
62+
63+
expect(result.current.metrics).toHaveLength(1);
64+
expect(result.current.metrics[0].name).toBe("users");
65+
});
66+
67+
it("handles fetch error", async () => {
68+
mockAnalyticsMeta.mockRejectedValue(new Error("Meta service down"));
69+
70+
const { result } = renderHook(() => useAnalyticsMeta());
71+
72+
await waitFor(() => {
73+
expect(result.current.isLoading).toBe(false);
74+
});
75+
76+
expect(result.current.error?.message).toBe("Meta service down");
77+
expect(result.current.metrics).toHaveLength(0);
78+
});
79+
80+
it("supports refetch", async () => {
81+
mockAnalyticsMeta
82+
.mockResolvedValueOnce({ metrics: [{ name: "a", label: "A" }] })
83+
.mockResolvedValueOnce({ metrics: [{ name: "a", label: "A" }, { name: "b", label: "B" }] });
84+
85+
const { result } = renderHook(() => useAnalyticsMeta());
86+
87+
await waitFor(() => {
88+
expect(result.current.isLoading).toBe(false);
89+
});
90+
91+
expect(result.current.metrics).toHaveLength(1);
92+
93+
await act(async () => {
94+
await result.current.refetch();
95+
});
96+
97+
expect(result.current.metrics).toHaveLength(2);
98+
});
99+
});

0 commit comments

Comments
 (0)