@stigmer/react 3.5.2 → 3.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/agent/AgentPicker.d.ts +10 -1
- package/agent/AgentPicker.d.ts.map +1 -1
- package/agent/AgentPicker.js +2 -2
- package/agent/AgentPicker.js.map +1 -1
- package/channel/AgentChannelsPanel.d.ts.map +1 -1
- package/channel/AgentChannelsPanel.js +22 -5
- package/channel/AgentChannelsPanel.js.map +1 -1
- package/channel/ChannelTemplatesDialog.d.ts +53 -0
- package/channel/ChannelTemplatesDialog.d.ts.map +1 -0
- package/channel/ChannelTemplatesDialog.js +150 -0
- package/channel/ChannelTemplatesDialog.js.map +1 -0
- package/channel/index.d.ts +8 -0
- package/channel/index.d.ts.map +1 -1
- package/channel/index.js +4 -0
- package/channel/index.js.map +1 -1
- package/channel/providers.d.ts +12 -0
- package/channel/providers.d.ts.map +1 -1
- package/channel/providers.js +14 -2
- package/channel/providers.js.map +1 -1
- package/channel/templatePresentation.d.ts +37 -0
- package/channel/templatePresentation.d.ts.map +1 -0
- package/channel/templatePresentation.js +69 -0
- package/channel/templatePresentation.js.map +1 -0
- package/channel/useChannelTemplateList.d.ts +64 -0
- package/channel/useChannelTemplateList.d.ts.map +1 -0
- package/channel/useChannelTemplateList.js +69 -0
- package/channel/useChannelTemplateList.js.map +1 -0
- package/channel/useChannelTemplateReadiness.d.ts +53 -0
- package/channel/useChannelTemplateReadiness.d.ts.map +1 -0
- package/channel/useChannelTemplateReadiness.js +42 -0
- package/channel/useChannelTemplateReadiness.js.map +1 -0
- package/dashboard/useDashboardFailedRuns.d.ts.map +1 -1
- package/dashboard/useDashboardFailedRuns.js +2 -0
- package/dashboard/useDashboardFailedRuns.js.map +1 -1
- package/index.d.ts +4 -2
- package/index.d.ts.map +1 -1
- package/index.js +9 -1
- package/index.js.map +1 -1
- package/library/useExportResource.d.ts +4 -3
- package/library/useExportResource.d.ts.map +1 -1
- package/library/useExportResource.js +1 -1
- package/library/useExportResource.js.map +1 -1
- package/organization/OrgProvider.d.ts +11 -4
- package/organization/OrgProvider.d.ts.map +1 -1
- package/organization/OrgProvider.js +28 -4
- package/organization/OrgProvider.js.map +1 -1
- package/package.json +4 -4
- package/resource-detail/useDeleteResource.d.ts +1 -1
- package/resource-detail/useDeleteResource.d.ts.map +1 -1
- package/resource-detail/useDeleteResource.js +5 -0
- package/resource-detail/useDeleteResource.js.map +1 -1
- package/resource-workbench/components/ResourceWorkbench.d.ts +10 -1
- package/resource-workbench/components/ResourceWorkbench.d.ts.map +1 -1
- package/resource-workbench/components/ResourceWorkbench.js +6 -2
- package/resource-workbench/components/ResourceWorkbench.js.map +1 -1
- package/resource-workbench/hooks/useResourceCollection.d.ts +8 -0
- package/resource-workbench/hooks/useResourceCollection.d.ts.map +1 -1
- package/resource-workbench/hooks/useResourceCollection.js +2 -2
- package/resource-workbench/hooks/useResourceCollection.js.map +1 -1
- package/schedule/CadenceField.d.ts +31 -0
- package/schedule/CadenceField.d.ts.map +1 -0
- package/schedule/CadenceField.js +116 -0
- package/schedule/CadenceField.js.map +1 -0
- package/schedule/ScheduleDetailView.d.ts +58 -0
- package/schedule/ScheduleDetailView.d.ts.map +1 -0
- package/schedule/ScheduleDetailView.js +236 -0
- package/schedule/ScheduleDetailView.js.map +1 -0
- package/schedule/ScheduleForm.d.ts +43 -0
- package/schedule/ScheduleForm.d.ts.map +1 -0
- package/schedule/ScheduleForm.js +129 -0
- package/schedule/ScheduleForm.js.map +1 -0
- package/schedule/ScheduleRowActions.d.ts +20 -0
- package/schedule/ScheduleRowActions.d.ts.map +1 -0
- package/schedule/ScheduleRowActions.js +54 -0
- package/schedule/ScheduleRowActions.js.map +1 -0
- package/schedule/TimeZoneField.d.ts +32 -0
- package/schedule/TimeZoneField.d.ts.map +1 -0
- package/schedule/TimeZoneField.js +104 -0
- package/schedule/TimeZoneField.js.map +1 -0
- package/schedule/cadence.d.ts +95 -0
- package/schedule/cadence.d.ts.map +1 -0
- package/schedule/cadence.js +242 -0
- package/schedule/cadence.js.map +1 -0
- package/schedule/index.d.ts +31 -0
- package/schedule/index.d.ts.map +1 -0
- package/schedule/index.js +16 -0
- package/schedule/index.js.map +1 -0
- package/schedule/scheduleColumns.d.ts +26 -0
- package/schedule/scheduleColumns.d.ts.map +1 -0
- package/schedule/scheduleColumns.js +77 -0
- package/schedule/scheduleColumns.js.map +1 -0
- package/schedule/scheduleListFn.d.ts +50 -0
- package/schedule/scheduleListFn.d.ts.map +1 -0
- package/schedule/scheduleListFn.js +65 -0
- package/schedule/scheduleListFn.js.map +1 -0
- package/schedule/scheduleState.d.ts +43 -0
- package/schedule/scheduleState.d.ts.map +1 -0
- package/schedule/scheduleState.js +67 -0
- package/schedule/scheduleState.js.map +1 -0
- package/schedule/useCreateSchedule.d.ts +57 -0
- package/schedule/useCreateSchedule.d.ts.map +1 -0
- package/schedule/useCreateSchedule.js +61 -0
- package/schedule/useCreateSchedule.js.map +1 -0
- package/schedule/useResumeSchedule.d.ts +30 -0
- package/schedule/useResumeSchedule.d.ts.map +1 -0
- package/schedule/useResumeSchedule.js +45 -0
- package/schedule/useResumeSchedule.js.map +1 -0
- package/schedule/useSchedule.d.ts +39 -0
- package/schedule/useSchedule.d.ts.map +1 -0
- package/schedule/useSchedule.js +44 -0
- package/schedule/useSchedule.js.map +1 -0
- package/schedule/useScheduleCount.d.ts +38 -0
- package/schedule/useScheduleCount.d.ts.map +1 -0
- package/schedule/useScheduleCount.js +30 -0
- package/schedule/useScheduleCount.js.map +1 -0
- package/schedule/useScheduleList.d.ts +48 -0
- package/schedule/useScheduleList.d.ts.map +1 -0
- package/schedule/useScheduleList.js +43 -0
- package/schedule/useScheduleList.js.map +1 -0
- package/schedule/useSetScheduleEnabled.d.ts +31 -0
- package/schedule/useSetScheduleEnabled.d.ts.map +1 -0
- package/schedule/useSetScheduleEnabled.js +53 -0
- package/schedule/useSetScheduleEnabled.js.map +1 -0
- package/schedule/useTriggerSchedule.d.ts +28 -0
- package/schedule/useTriggerSchedule.d.ts.map +1 -0
- package/schedule/useTriggerSchedule.js +43 -0
- package/schedule/useTriggerSchedule.js.map +1 -0
- package/search/useResourceCount.d.ts +2 -1
- package/search/useResourceCount.d.ts.map +1 -1
- package/search/useResourceCount.js +6 -1
- package/search/useResourceCount.js.map +1 -1
- package/src/agent/AgentPicker.tsx +13 -1
- package/src/channel/AgentChannelsPanel.tsx +43 -1
- package/src/channel/ChannelTemplatesDialog.tsx +433 -0
- package/src/channel/__tests__/AgentChannelsPanel.test.tsx +73 -0
- package/src/channel/__tests__/ChannelTemplatesDialog.test.tsx +292 -0
- package/src/channel/__tests__/providers.test.ts +9 -0
- package/src/channel/__tests__/templatePresentation.test.ts +96 -0
- package/src/channel/__tests__/useChannelTemplateList.test.tsx +216 -0
- package/src/channel/__tests__/useChannelTemplateReadiness.test.tsx +124 -0
- package/src/channel/index.ts +15 -0
- package/src/channel/providers.ts +26 -2
- package/src/channel/templatePresentation.ts +82 -0
- package/src/channel/useChannelTemplateList.ts +123 -0
- package/src/channel/useChannelTemplateReadiness.ts +69 -0
- package/src/dashboard/__tests__/useDashboardFailedRuns.test.tsx +106 -0
- package/src/dashboard/useDashboardFailedRuns.ts +2 -0
- package/src/execution/__tests__/PlanArtifactCard.test.tsx +23 -2
- package/src/index.ts +65 -0
- package/src/library/useExportResource.ts +4 -3
- package/src/organization/OrgProvider.tsx +29 -4
- package/src/organization/__tests__/OrgProvider.orgSwitch.test.tsx +89 -0
- package/src/resource-detail/useDeleteResource.ts +11 -1
- package/src/resource-workbench/__tests__/ResourceWorkbench.test.tsx +112 -0
- package/src/resource-workbench/components/ResourceWorkbench.tsx +32 -15
- package/src/resource-workbench/hooks/useResourceCollection.ts +11 -1
- package/src/schedule/CadenceField.tsx +365 -0
- package/src/schedule/ScheduleDetailView.tsx +583 -0
- package/src/schedule/ScheduleForm.tsx +405 -0
- package/src/schedule/ScheduleRowActions.tsx +124 -0
- package/src/schedule/TimeZoneField.tsx +199 -0
- package/src/schedule/__tests__/ScheduleDetailView.test.tsx +260 -0
- package/src/schedule/__tests__/cadence.test.ts +278 -0
- package/src/schedule/__tests__/scheduleColumnsRowActions.test.tsx +176 -0
- package/src/schedule/__tests__/scheduleCreation.test.tsx +340 -0
- package/src/schedule/__tests__/scheduleHooks.test.tsx +312 -0
- package/src/schedule/__tests__/scheduleListFn.test.ts +128 -0
- package/src/schedule/__tests__/scheduleState.test.ts +133 -0
- package/src/schedule/cadence.ts +298 -0
- package/src/schedule/index.ts +57 -0
- package/src/schedule/scheduleColumns.tsx +118 -0
- package/src/schedule/scheduleListFn.ts +108 -0
- package/src/schedule/scheduleState.ts +96 -0
- package/src/schedule/useCreateSchedule.ts +88 -0
- package/src/schedule/useResumeSchedule.ts +67 -0
- package/src/schedule/useSchedule.ts +67 -0
- package/src/schedule/useScheduleCount.ts +57 -0
- package/src/schedule/useScheduleList.ts +83 -0
- package/src/schedule/useSetScheduleEnabled.ts +84 -0
- package/src/schedule/useTriggerSchedule.ts +65 -0
- package/src/search/__tests__/useResourceCount.test.tsx +89 -0
- package/src/search/useResourceCount.ts +8 -2
- package/src/workflow/WorkflowDashboard.tsx +1 -0
- package/src/workflow/useWorkflowExecutionList.ts +13 -2
- package/styles.css +1 -1
- package/workflow/CanvasTransitionEdge.d.ts +1 -1
- package/workflow/WorkflowDashboard.d.ts.map +1 -1
- package/workflow/WorkflowDashboard.js +1 -0
- package/workflow/WorkflowDashboard.js.map +1 -1
- package/workflow/useWorkflowExecutionList.d.ts +10 -0
- package/workflow/useWorkflowExecutionList.d.ts.map +1 -1
- package/workflow/useWorkflowExecutionList.js +3 -2
- package/workflow/useWorkflowExecutionList.js.map +1 -1
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
2
|
+
import { render, screen, waitFor, cleanup, fireEvent } from "@testing-library/react";
|
|
3
|
+
import type { ReactNode } from "react";
|
|
4
|
+
import type { AgentChannel } from "@stigmer/protos/ai/stigmer/agentic/agentchannel/v1/api_pb";
|
|
5
|
+
import { AgentChannelInstallState } from "@stigmer/protos/ai/stigmer/agentic/agentchannel/v1/status_pb";
|
|
6
|
+
import { StigmerContext } from "../../context";
|
|
7
|
+
import { DeploymentModeContext } from "../../deployment-mode";
|
|
8
|
+
import { FetchCacheContext } from "../../internal/FetchCacheProvider";
|
|
9
|
+
import { ChannelTemplatesDialog } from "../ChannelTemplatesDialog";
|
|
10
|
+
|
|
11
|
+
function makeChannel(overrides?: {
|
|
12
|
+
installState?: AgentChannelInstallState;
|
|
13
|
+
enabled?: boolean;
|
|
14
|
+
proactiveMessagingEnabled?: boolean;
|
|
15
|
+
}): AgentChannel {
|
|
16
|
+
return {
|
|
17
|
+
metadata: { id: "ach_1", org: "acme", slug: "wa-main", name: "WA Main" },
|
|
18
|
+
spec: {
|
|
19
|
+
enabled: overrides?.enabled ?? true,
|
|
20
|
+
proactiveMessagingEnabled: overrides?.proactiveMessagingEnabled ?? true,
|
|
21
|
+
providerConfig: { case: "whatsapp", value: { phoneNumberId: "106" } },
|
|
22
|
+
},
|
|
23
|
+
status: {
|
|
24
|
+
installState:
|
|
25
|
+
overrides?.installState ?? AgentChannelInstallState.installed,
|
|
26
|
+
providerStatus: { case: undefined },
|
|
27
|
+
},
|
|
28
|
+
} as unknown as AgentChannel;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function makeTemplate(overrides?: Partial<Record<string, unknown>>) {
|
|
32
|
+
return {
|
|
33
|
+
name: "fee_reminder",
|
|
34
|
+
language: "en_US",
|
|
35
|
+
category: "UTILITY",
|
|
36
|
+
status: "APPROVED",
|
|
37
|
+
parameterFormat: "POSITIONAL",
|
|
38
|
+
parameterNames: ["1", "2"],
|
|
39
|
+
bodyText: "Hi {{1}}, your fee of {{2}} is due.",
|
|
40
|
+
headerFormat: "",
|
|
41
|
+
rejectionReason: "",
|
|
42
|
+
unsupportedReason: "",
|
|
43
|
+
...overrides,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function renderDialog(
|
|
48
|
+
listTemplates: (input: unknown) => Promise<unknown>,
|
|
49
|
+
props?: {
|
|
50
|
+
channel?: AgentChannel;
|
|
51
|
+
onEditYaml?: () => void;
|
|
52
|
+
mode?: "cloud" | "local";
|
|
53
|
+
},
|
|
54
|
+
) {
|
|
55
|
+
const client = { agentChannel: { listTemplates } } as never;
|
|
56
|
+
function Wrapper({ children }: { children: ReactNode }) {
|
|
57
|
+
return (
|
|
58
|
+
<FetchCacheContext.Provider value={null}>
|
|
59
|
+
<StigmerContext.Provider value={client}>
|
|
60
|
+
<DeploymentModeContext.Provider value={props?.mode ?? "cloud"}>
|
|
61
|
+
{children}
|
|
62
|
+
</DeploymentModeContext.Provider>
|
|
63
|
+
</StigmerContext.Provider>
|
|
64
|
+
</FetchCacheContext.Provider>
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
return render(
|
|
68
|
+
<ChannelTemplatesDialog
|
|
69
|
+
open
|
|
70
|
+
onOpenChange={vi.fn()}
|
|
71
|
+
channel={props?.channel ?? makeChannel()}
|
|
72
|
+
onEditYaml={props?.onEditYaml}
|
|
73
|
+
modal={false}
|
|
74
|
+
/>,
|
|
75
|
+
{ wrapper: Wrapper },
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
describe("ChannelTemplatesDialog", () => {
|
|
80
|
+
beforeEach(() => {
|
|
81
|
+
vi.restoreAllMocks();
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
afterEach(() => {
|
|
85
|
+
cleanup();
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
// -------------------------------------------------------------------------
|
|
89
|
+
// Readiness teaching states — no network call fires for any of them
|
|
90
|
+
// -------------------------------------------------------------------------
|
|
91
|
+
|
|
92
|
+
it("renders the cloud notice on a local deployment without fetching", () => {
|
|
93
|
+
const listTemplates = vi.fn();
|
|
94
|
+
renderDialog(listTemplates, { mode: "local" });
|
|
95
|
+
|
|
96
|
+
expect(
|
|
97
|
+
screen.getByText(/template registry can only be read on a cloud/),
|
|
98
|
+
).toBeDefined();
|
|
99
|
+
expect(listTemplates).not.toHaveBeenCalled();
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("teaches connecting first for a pending install, with the provider's own sentence", () => {
|
|
103
|
+
const listTemplates = vi.fn();
|
|
104
|
+
renderDialog(listTemplates, {
|
|
105
|
+
channel: makeChannel({
|
|
106
|
+
installState: AgentChannelInstallState.pending_install,
|
|
107
|
+
}),
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
expect(screen.getByText("Connect this channel first")).toBeDefined();
|
|
111
|
+
// The card's describeChannel copy, reused verbatim.
|
|
112
|
+
expect(
|
|
113
|
+
screen.getByText(/WhatsApp connection hasn't been completed/),
|
|
114
|
+
).toBeDefined();
|
|
115
|
+
expect(listTemplates).not.toHaveBeenCalled();
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it("teaches the serving switch when the channel is off", () => {
|
|
119
|
+
const listTemplates = vi.fn();
|
|
120
|
+
renderDialog(listTemplates, {
|
|
121
|
+
channel: makeChannel({ enabled: false }),
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
expect(screen.getByText("This channel is turned off")).toBeDefined();
|
|
125
|
+
expect(listTemplates).not.toHaveBeenCalled();
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it("teaches the proactive grant by its field name and routes to the YAML editor", () => {
|
|
129
|
+
const listTemplates = vi.fn();
|
|
130
|
+
const onEditYaml = vi.fn();
|
|
131
|
+
renderDialog(listTemplates, {
|
|
132
|
+
channel: makeChannel({ proactiveMessagingEnabled: false }),
|
|
133
|
+
onEditYaml,
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
expect(
|
|
137
|
+
screen.getByText("Business-initiated messaging is not enabled"),
|
|
138
|
+
).toBeDefined();
|
|
139
|
+
// The description names the exact spec field — the only fix today.
|
|
140
|
+
expect(
|
|
141
|
+
screen.getByText(/proactive_messaging_enabled/),
|
|
142
|
+
).toBeDefined();
|
|
143
|
+
|
|
144
|
+
fireEvent.click(
|
|
145
|
+
screen.getByRole("button", { name: "Edit channel YAML" }),
|
|
146
|
+
);
|
|
147
|
+
expect(onEditYaml).toHaveBeenCalledTimes(1);
|
|
148
|
+
expect(listTemplates).not.toHaveBeenCalled();
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it("omits the YAML affordance when the host wires none", () => {
|
|
152
|
+
renderDialog(vi.fn(), {
|
|
153
|
+
channel: makeChannel({ proactiveMessagingEnabled: false }),
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
expect(
|
|
157
|
+
screen.queryByRole("button", { name: "Edit channel YAML" }),
|
|
158
|
+
).toBeNull();
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
// -------------------------------------------------------------------------
|
|
162
|
+
// The fetched states
|
|
163
|
+
// -------------------------------------------------------------------------
|
|
164
|
+
|
|
165
|
+
it("fetches by the channel's own slug and org", async () => {
|
|
166
|
+
const listTemplates = vi.fn().mockResolvedValue({ entries: [] });
|
|
167
|
+
renderDialog(listTemplates);
|
|
168
|
+
|
|
169
|
+
await waitFor(() =>
|
|
170
|
+
expect(listTemplates).toHaveBeenCalledWith(
|
|
171
|
+
expect.objectContaining({ channel: "wa-main", org: "acme" }),
|
|
172
|
+
),
|
|
173
|
+
);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it("relays a server refusal verbatim", async () => {
|
|
177
|
+
const listTemplates = vi
|
|
178
|
+
.fn()
|
|
179
|
+
.mockRejectedValue(
|
|
180
|
+
new Error(
|
|
181
|
+
"the channel provider's template registry is temporarily unreachable — try again shortly",
|
|
182
|
+
),
|
|
183
|
+
);
|
|
184
|
+
renderDialog(listTemplates);
|
|
185
|
+
|
|
186
|
+
await waitFor(() =>
|
|
187
|
+
expect(
|
|
188
|
+
screen.getByText(/template registry is temporarily unreachable/),
|
|
189
|
+
).toBeDefined(),
|
|
190
|
+
);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it('says "found" (never "you have none") when the registry answers empty, and links WhatsApp Manager', async () => {
|
|
194
|
+
const listTemplates = vi.fn().mockResolvedValue({ entries: [] });
|
|
195
|
+
renderDialog(listTemplates);
|
|
196
|
+
|
|
197
|
+
await waitFor(() =>
|
|
198
|
+
expect(
|
|
199
|
+
screen.getByText("No templates found for this channel"),
|
|
200
|
+
).toBeDefined(),
|
|
201
|
+
);
|
|
202
|
+
|
|
203
|
+
const link = screen.getByRole("link", { name: /Open WhatsApp Manager/ });
|
|
204
|
+
expect(link.getAttribute("href")).toBe(
|
|
205
|
+
"https://business.facebook.com/wa/manage/message-templates/",
|
|
206
|
+
);
|
|
207
|
+
expect(link.getAttribute("target")).toBe("_blank");
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it("renders the count header, identity, facts, and the verbatim status badge", async () => {
|
|
211
|
+
const listTemplates = vi.fn().mockResolvedValue({
|
|
212
|
+
entries: [
|
|
213
|
+
makeTemplate(),
|
|
214
|
+
makeTemplate({
|
|
215
|
+
name: "appeal_pending",
|
|
216
|
+
status: "IN_APPEAL",
|
|
217
|
+
category: "MARKETING",
|
|
218
|
+
headerFormat: "IMAGE",
|
|
219
|
+
bodyText: "Season offer!",
|
|
220
|
+
}),
|
|
221
|
+
],
|
|
222
|
+
});
|
|
223
|
+
renderDialog(listTemplates);
|
|
224
|
+
|
|
225
|
+
await waitFor(() =>
|
|
226
|
+
expect(screen.getByText(/2 templates/)).toBeDefined(),
|
|
227
|
+
);
|
|
228
|
+
// Only the APPROVED one counts as sendable.
|
|
229
|
+
expect(screen.getByText(/1 ready to send/)).toBeDefined();
|
|
230
|
+
|
|
231
|
+
expect(screen.getByText("fee_reminder")).toBeDefined();
|
|
232
|
+
expect(screen.getAllByText("en_US")).toHaveLength(2);
|
|
233
|
+
expect(screen.getByText("UTILITY")).toBeDefined();
|
|
234
|
+
expect(screen.getByText("IMAGE header")).toBeDefined();
|
|
235
|
+
// Provider vocabulary verbatim — even statuses this build predates
|
|
236
|
+
// render as the provider's own word, never re-encoded.
|
|
237
|
+
expect(screen.getByText("APPROVED")).toBeDefined();
|
|
238
|
+
expect(screen.getByText("IN_APPEAL")).toBeDefined();
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
it("sets placeholders off from the literal body text", async () => {
|
|
242
|
+
const listTemplates = vi
|
|
243
|
+
.fn()
|
|
244
|
+
.mockResolvedValue({ entries: [makeTemplate()] });
|
|
245
|
+
const { container } = renderDialog(listTemplates);
|
|
246
|
+
|
|
247
|
+
await waitFor(() => expect(screen.getByText("fee_reminder")).toBeDefined());
|
|
248
|
+
|
|
249
|
+
const placeholders = [...container.querySelectorAll("code")].map(
|
|
250
|
+
(el) => el.textContent,
|
|
251
|
+
);
|
|
252
|
+
expect(placeholders).toEqual(["{{1}}", "{{2}}"]);
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
it("shows Stigmer's unsupported verdict on an approved-but-unsendable template", async () => {
|
|
256
|
+
const listTemplates = vi.fn().mockResolvedValue({
|
|
257
|
+
entries: [
|
|
258
|
+
makeTemplate({
|
|
259
|
+
name: "flow_template",
|
|
260
|
+
unsupportedReason:
|
|
261
|
+
"this template has a FLOW button, which this platform version cannot supply parameters for",
|
|
262
|
+
}),
|
|
263
|
+
],
|
|
264
|
+
});
|
|
265
|
+
renderDialog(listTemplates);
|
|
266
|
+
|
|
267
|
+
await waitFor(() =>
|
|
268
|
+
expect(
|
|
269
|
+
screen.getByText(/Not sendable: this template has a FLOW button/),
|
|
270
|
+
).toBeDefined(),
|
|
271
|
+
);
|
|
272
|
+
// Approved yet unsendable — the count must not claim it is ready.
|
|
273
|
+
expect(screen.getByText(/0 ready to send/)).toBeDefined();
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
it("shows the provider's rejection verdict verbatim", async () => {
|
|
277
|
+
const listTemplates = vi.fn().mockResolvedValue({
|
|
278
|
+
entries: [
|
|
279
|
+
makeTemplate({
|
|
280
|
+
name: "promo_blast",
|
|
281
|
+
status: "REJECTED",
|
|
282
|
+
rejectionReason: "PROMOTIONAL",
|
|
283
|
+
}),
|
|
284
|
+
],
|
|
285
|
+
});
|
|
286
|
+
renderDialog(listTemplates);
|
|
287
|
+
|
|
288
|
+
await waitFor(() =>
|
|
289
|
+
expect(screen.getByText("Rejected: PROMOTIONAL")).toBeDefined(),
|
|
290
|
+
);
|
|
291
|
+
});
|
|
292
|
+
});
|
|
@@ -38,6 +38,15 @@ describe("channel provider registry", () => {
|
|
|
38
38
|
expect(channelProviderOf("telegram")).toBeNull();
|
|
39
39
|
expect(channelProviderOf(undefined)).toBeNull();
|
|
40
40
|
});
|
|
41
|
+
|
|
42
|
+
it("declares the template capability per provider — WhatsApp only", () => {
|
|
43
|
+
// Mirrors the server's proactive-sender registry (the de-facto
|
|
44
|
+
// provider filter behind listTemplates). Slack's false is not a
|
|
45
|
+
// gap: Slack has no template concept, so the Templates affordance
|
|
46
|
+
// hides rather than disables.
|
|
47
|
+
expect(channelProviderOf("whatsapp")?.supportsMessageTemplates).toBe(true);
|
|
48
|
+
expect(channelProviderOf("slack")?.supportsMessageTemplates).toBe(false);
|
|
49
|
+
});
|
|
41
50
|
});
|
|
42
51
|
|
|
43
52
|
describe("channel provider presentation", () => {
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
splitTemplateBody,
|
|
4
|
+
templateStatusPhase,
|
|
5
|
+
} from "../templatePresentation";
|
|
6
|
+
|
|
7
|
+
describe("templateStatusPhase", () => {
|
|
8
|
+
// The documented five (DD-003) plus the observed extras (DD-005 D7:
|
|
9
|
+
// Meta's live vocabulary exceeds the documented set).
|
|
10
|
+
it.each([
|
|
11
|
+
["APPROVED", "ready"],
|
|
12
|
+
["PENDING", "pending"],
|
|
13
|
+
["IN_APPEAL", "pending"],
|
|
14
|
+
["PAUSED", "degraded"],
|
|
15
|
+
["LIMIT_EXCEEDED", "degraded"],
|
|
16
|
+
["REJECTED", "failed"],
|
|
17
|
+
["DISABLED", "failed"],
|
|
18
|
+
["PENDING_DELETION", "disabled"],
|
|
19
|
+
["DELETED", "disabled"],
|
|
20
|
+
] as const)("maps %s to %s", (status, phase) => {
|
|
21
|
+
expect(templateStatusPhase(status)).toBe(phase);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("lands unknown statuses on draft — Meta can add states this build predates", () => {
|
|
25
|
+
expect(templateStatusPhase("SOME_FUTURE_STATE")).toBe("draft");
|
|
26
|
+
expect(templateStatusPhase("")).toBe("draft");
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("is case-sensitive like the wire — Meta emits uppercase, anything else is unknown", () => {
|
|
30
|
+
// The server relays Meta's verbatim uppercase strings; a lowercase
|
|
31
|
+
// variant is not a known status and must not color as one.
|
|
32
|
+
expect(templateStatusPhase("approved")).toBe("draft");
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
describe("splitTemplateBody", () => {
|
|
37
|
+
it("returns one text segment for a body without placeholders", () => {
|
|
38
|
+
expect(splitTemplateBody("Your class is confirmed.")).toEqual([
|
|
39
|
+
{ kind: "text", value: "Your class is confirmed." },
|
|
40
|
+
]);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("splits positional placeholders out of surrounding text", () => {
|
|
44
|
+
expect(
|
|
45
|
+
splitTemplateBody("Hi {{1}}, your fee of {{2}} is due {{3}}."),
|
|
46
|
+
).toEqual([
|
|
47
|
+
{ kind: "text", value: "Hi " },
|
|
48
|
+
{ kind: "placeholder", value: "{{1}}" },
|
|
49
|
+
{ kind: "text", value: ", your fee of " },
|
|
50
|
+
{ kind: "placeholder", value: "{{2}}" },
|
|
51
|
+
{ kind: "text", value: " is due " },
|
|
52
|
+
{ kind: "placeholder", value: "{{3}}" },
|
|
53
|
+
{ kind: "text", value: "." },
|
|
54
|
+
]);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("recognizes named placeholders", () => {
|
|
58
|
+
expect(splitTemplateBody("Hello {{customer_name}}!")).toEqual([
|
|
59
|
+
{ kind: "text", value: "Hello " },
|
|
60
|
+
{ kind: "placeholder", value: "{{customer_name}}" },
|
|
61
|
+
{ kind: "text", value: "!" },
|
|
62
|
+
]);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("handles adjacent placeholders and placeholder-only bodies", () => {
|
|
66
|
+
expect(splitTemplateBody("{{1}}{{2}}")).toEqual([
|
|
67
|
+
{ kind: "placeholder", value: "{{1}}" },
|
|
68
|
+
{ kind: "placeholder", value: "{{2}}" },
|
|
69
|
+
]);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("treats single or unbalanced braces as literal text", () => {
|
|
73
|
+
expect(splitTemplateBody("a {1} b {{unclosed")).toEqual([
|
|
74
|
+
{ kind: "text", value: "a {1} b {{unclosed" },
|
|
75
|
+
]);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("returns no segments for an empty body", () => {
|
|
79
|
+
expect(splitTemplateBody("")).toEqual([]);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("never rewrites — segments concatenate back to the input verbatim", () => {
|
|
83
|
+
const bodies = [
|
|
84
|
+
"Hi {{1}}, {{2}} due {{3}}.",
|
|
85
|
+
" leading and trailing ",
|
|
86
|
+
"{{ spaced }} stays verbatim",
|
|
87
|
+
"mixed {{name}} and {{1}}",
|
|
88
|
+
];
|
|
89
|
+
for (const body of bodies) {
|
|
90
|
+
const joined = splitTemplateBody(body)
|
|
91
|
+
.map((s) => s.value)
|
|
92
|
+
.join("");
|
|
93
|
+
expect(joined).toBe(body);
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
});
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
2
|
+
import { renderHook, waitFor, act } from "@testing-library/react";
|
|
3
|
+
import type { ReactNode } from "react";
|
|
4
|
+
import type { ListChannelTemplatesInput } from "@stigmer/protos/ai/stigmer/agentic/agentchannel/v1/message_io_pb";
|
|
5
|
+
import { StigmerContext } from "../../context";
|
|
6
|
+
import { FetchCacheContext } from "../../internal/FetchCacheProvider";
|
|
7
|
+
import { useChannelTemplateList } from "../useChannelTemplateList";
|
|
8
|
+
|
|
9
|
+
function createMockStigmer(overrides: {
|
|
10
|
+
listTemplates?: (input: ListChannelTemplatesInput) => Promise<unknown>;
|
|
11
|
+
} = {}) {
|
|
12
|
+
return {
|
|
13
|
+
agentChannel: {
|
|
14
|
+
listTemplates:
|
|
15
|
+
overrides.listTemplates ?? vi.fn().mockResolvedValue({ entries: [] }),
|
|
16
|
+
},
|
|
17
|
+
} as never;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function wrapper(client: unknown) {
|
|
21
|
+
return function Wrapper({ children }: { children: ReactNode }) {
|
|
22
|
+
return (
|
|
23
|
+
<FetchCacheContext.Provider value={null}>
|
|
24
|
+
<StigmerContext.Provider value={client as never}>
|
|
25
|
+
{children}
|
|
26
|
+
</StigmerContext.Provider>
|
|
27
|
+
</FetchCacheContext.Provider>
|
|
28
|
+
);
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function makeTemplate(name: string, status: string) {
|
|
33
|
+
return {
|
|
34
|
+
name,
|
|
35
|
+
language: "en_US",
|
|
36
|
+
category: "UTILITY",
|
|
37
|
+
status,
|
|
38
|
+
parameterFormat: "POSITIONAL",
|
|
39
|
+
parameterNames: [],
|
|
40
|
+
bodyText: `Hi {{1}}, this is ${name}.`,
|
|
41
|
+
headerFormat: "",
|
|
42
|
+
rejectionReason: "",
|
|
43
|
+
unsupportedReason: "",
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function flush(): Promise<void> {
|
|
48
|
+
await act(async () => {
|
|
49
|
+
await Promise.resolve();
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
describe("useChannelTemplateList", () => {
|
|
54
|
+
beforeEach(() => {
|
|
55
|
+
vi.restoreAllMocks();
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
afterEach(() => {
|
|
59
|
+
vi.useRealTimers();
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("loads templates by channel slug + org with the exact wire input", async () => {
|
|
63
|
+
const listTemplates = vi
|
|
64
|
+
.fn()
|
|
65
|
+
.mockResolvedValue({ entries: [makeTemplate("fee_reminder", "APPROVED")] });
|
|
66
|
+
const client = createMockStigmer({ listTemplates });
|
|
67
|
+
|
|
68
|
+
const { result } = renderHook(
|
|
69
|
+
() => useChannelTemplateList("wa-main", "acme"),
|
|
70
|
+
{ wrapper: wrapper(client) },
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
expect(result.current.isLoading).toBe(true);
|
|
74
|
+
await waitFor(() => expect(result.current.isLoading).toBe(false));
|
|
75
|
+
|
|
76
|
+
expect(result.current.templates).toHaveLength(1);
|
|
77
|
+
expect(result.current.templates[0].name).toBe("fee_reminder");
|
|
78
|
+
expect(result.current.error).toBeNull();
|
|
79
|
+
// The RPC is keyed on slug + org, and the console's default leaves
|
|
80
|
+
// approvedOnly unset so every status renders with its badge.
|
|
81
|
+
expect(listTemplates).toHaveBeenCalledWith(
|
|
82
|
+
expect.objectContaining({
|
|
83
|
+
channel: "wa-main",
|
|
84
|
+
org: "acme",
|
|
85
|
+
approvedOnly: false,
|
|
86
|
+
}),
|
|
87
|
+
);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("passes approvedOnly through when a caller sets it", async () => {
|
|
91
|
+
const listTemplates = vi.fn().mockResolvedValue({ entries: [] });
|
|
92
|
+
const client = createMockStigmer({ listTemplates });
|
|
93
|
+
|
|
94
|
+
renderHook(
|
|
95
|
+
() => useChannelTemplateList("wa-main", "acme", { approvedOnly: true }),
|
|
96
|
+
{ wrapper: wrapper(client) },
|
|
97
|
+
);
|
|
98
|
+
|
|
99
|
+
await waitFor(() =>
|
|
100
|
+
expect(listTemplates).toHaveBeenCalledWith(
|
|
101
|
+
expect.objectContaining({ approvedOnly: true }),
|
|
102
|
+
),
|
|
103
|
+
);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("skips fetching when the channel slug is empty (stable no-op)", async () => {
|
|
107
|
+
const listTemplates = vi.fn();
|
|
108
|
+
const client = createMockStigmer({ listTemplates });
|
|
109
|
+
|
|
110
|
+
const { result } = renderHook(() => useChannelTemplateList("", "acme"), {
|
|
111
|
+
wrapper: wrapper(client),
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
await waitFor(() => expect(result.current.isLoading).toBe(false));
|
|
115
|
+
expect(result.current.templates).toEqual([]);
|
|
116
|
+
expect(listTemplates).not.toHaveBeenCalled();
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("skips fetching when the org is empty (stable no-op)", async () => {
|
|
120
|
+
const listTemplates = vi.fn();
|
|
121
|
+
const client = createMockStigmer({ listTemplates });
|
|
122
|
+
|
|
123
|
+
const { result } = renderHook(() => useChannelTemplateList("wa-main", ""), {
|
|
124
|
+
wrapper: wrapper(client),
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
await waitFor(() => expect(result.current.isLoading).toBe(false));
|
|
128
|
+
expect(result.current.templates).toEqual([]);
|
|
129
|
+
expect(listTemplates).not.toHaveBeenCalled();
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("surfaces the server's refusal verbatim", async () => {
|
|
133
|
+
const listTemplates = vi
|
|
134
|
+
.fn()
|
|
135
|
+
.mockRejectedValue(
|
|
136
|
+
new Error(
|
|
137
|
+
"unauthorized to use business messaging on this agent channel",
|
|
138
|
+
),
|
|
139
|
+
);
|
|
140
|
+
const client = createMockStigmer({ listTemplates });
|
|
141
|
+
|
|
142
|
+
const { result } = renderHook(
|
|
143
|
+
() => useChannelTemplateList("wa-main", "acme"),
|
|
144
|
+
{ wrapper: wrapper(client) },
|
|
145
|
+
);
|
|
146
|
+
|
|
147
|
+
await waitFor(() => expect(result.current.error).not.toBeNull());
|
|
148
|
+
expect(result.current.error?.message).toBe(
|
|
149
|
+
"unauthorized to use business messaging on this agent channel",
|
|
150
|
+
);
|
|
151
|
+
expect(result.current.templates).toEqual([]);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it("polls at 60s while a template is PENDING and stops once none are", async () => {
|
|
155
|
+
vi.useFakeTimers();
|
|
156
|
+
|
|
157
|
+
// First answer holds a PENDING entry; every later answer is settled.
|
|
158
|
+
let calls = 0;
|
|
159
|
+
const listTemplates = vi.fn(async () => {
|
|
160
|
+
calls += 1;
|
|
161
|
+
return {
|
|
162
|
+
entries:
|
|
163
|
+
calls === 1
|
|
164
|
+
? [makeTemplate("fee_reminder", "PENDING")]
|
|
165
|
+
: [makeTemplate("fee_reminder", "APPROVED")],
|
|
166
|
+
};
|
|
167
|
+
});
|
|
168
|
+
const client = createMockStigmer({ listTemplates });
|
|
169
|
+
|
|
170
|
+
const { result } = renderHook(
|
|
171
|
+
() => useChannelTemplateList("wa-main", "acme"),
|
|
172
|
+
{ wrapper: wrapper(client) },
|
|
173
|
+
);
|
|
174
|
+
|
|
175
|
+
await flush();
|
|
176
|
+
expect(listTemplates).toHaveBeenCalledTimes(1);
|
|
177
|
+
expect(result.current.templates[0].status).toBe("PENDING");
|
|
178
|
+
|
|
179
|
+
// The pending entry arms the poll; one interval later it re-reads.
|
|
180
|
+
act(() => {
|
|
181
|
+
vi.advanceTimersByTime(60_000);
|
|
182
|
+
});
|
|
183
|
+
await flush();
|
|
184
|
+
expect(listTemplates).toHaveBeenCalledTimes(2);
|
|
185
|
+
expect(result.current.templates[0].status).toBe("APPROVED");
|
|
186
|
+
|
|
187
|
+
// Settled data disarms the poll — no further reads, ever.
|
|
188
|
+
act(() => {
|
|
189
|
+
vi.advanceTimersByTime(300_000);
|
|
190
|
+
});
|
|
191
|
+
await flush();
|
|
192
|
+
expect(listTemplates).toHaveBeenCalledTimes(2);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it("never polls when the first answer is already settled", async () => {
|
|
196
|
+
vi.useFakeTimers();
|
|
197
|
+
|
|
198
|
+
const listTemplates = vi.fn(async () => ({
|
|
199
|
+
entries: [makeTemplate("fee_reminder", "APPROVED")],
|
|
200
|
+
}));
|
|
201
|
+
const client = createMockStigmer({ listTemplates });
|
|
202
|
+
|
|
203
|
+
renderHook(() => useChannelTemplateList("wa-main", "acme"), {
|
|
204
|
+
wrapper: wrapper(client),
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
await flush();
|
|
208
|
+
expect(listTemplates).toHaveBeenCalledTimes(1);
|
|
209
|
+
|
|
210
|
+
act(() => {
|
|
211
|
+
vi.advanceTimersByTime(300_000);
|
|
212
|
+
});
|
|
213
|
+
await flush();
|
|
214
|
+
expect(listTemplates).toHaveBeenCalledTimes(1);
|
|
215
|
+
});
|
|
216
|
+
});
|