@stigmer/react 3.5.3 → 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.
Files changed (192) hide show
  1. package/agent/AgentPicker.d.ts +10 -1
  2. package/agent/AgentPicker.d.ts.map +1 -1
  3. package/agent/AgentPicker.js +2 -2
  4. package/agent/AgentPicker.js.map +1 -1
  5. package/channel/AgentChannelsPanel.d.ts.map +1 -1
  6. package/channel/AgentChannelsPanel.js +22 -5
  7. package/channel/AgentChannelsPanel.js.map +1 -1
  8. package/channel/ChannelTemplatesDialog.d.ts +53 -0
  9. package/channel/ChannelTemplatesDialog.d.ts.map +1 -0
  10. package/channel/ChannelTemplatesDialog.js +150 -0
  11. package/channel/ChannelTemplatesDialog.js.map +1 -0
  12. package/channel/index.d.ts +8 -0
  13. package/channel/index.d.ts.map +1 -1
  14. package/channel/index.js +4 -0
  15. package/channel/index.js.map +1 -1
  16. package/channel/providers.d.ts +12 -0
  17. package/channel/providers.d.ts.map +1 -1
  18. package/channel/providers.js +14 -2
  19. package/channel/providers.js.map +1 -1
  20. package/channel/templatePresentation.d.ts +37 -0
  21. package/channel/templatePresentation.d.ts.map +1 -0
  22. package/channel/templatePresentation.js +69 -0
  23. package/channel/templatePresentation.js.map +1 -0
  24. package/channel/useChannelTemplateList.d.ts +64 -0
  25. package/channel/useChannelTemplateList.d.ts.map +1 -0
  26. package/channel/useChannelTemplateList.js +69 -0
  27. package/channel/useChannelTemplateList.js.map +1 -0
  28. package/channel/useChannelTemplateReadiness.d.ts +53 -0
  29. package/channel/useChannelTemplateReadiness.d.ts.map +1 -0
  30. package/channel/useChannelTemplateReadiness.js +42 -0
  31. package/channel/useChannelTemplateReadiness.js.map +1 -0
  32. package/dashboard/useDashboardFailedRuns.d.ts.map +1 -1
  33. package/dashboard/useDashboardFailedRuns.js +2 -0
  34. package/dashboard/useDashboardFailedRuns.js.map +1 -1
  35. package/index.d.ts +4 -2
  36. package/index.d.ts.map +1 -1
  37. package/index.js +9 -1
  38. package/index.js.map +1 -1
  39. package/library/useExportResource.d.ts +4 -3
  40. package/library/useExportResource.d.ts.map +1 -1
  41. package/library/useExportResource.js +1 -1
  42. package/library/useExportResource.js.map +1 -1
  43. package/organization/OrgProvider.d.ts +11 -4
  44. package/organization/OrgProvider.d.ts.map +1 -1
  45. package/organization/OrgProvider.js +28 -4
  46. package/organization/OrgProvider.js.map +1 -1
  47. package/package.json +4 -4
  48. package/resource-detail/useDeleteResource.d.ts +1 -1
  49. package/resource-detail/useDeleteResource.d.ts.map +1 -1
  50. package/resource-detail/useDeleteResource.js +5 -0
  51. package/resource-detail/useDeleteResource.js.map +1 -1
  52. package/resource-workbench/components/ResourceWorkbench.d.ts +10 -1
  53. package/resource-workbench/components/ResourceWorkbench.d.ts.map +1 -1
  54. package/resource-workbench/components/ResourceWorkbench.js +6 -2
  55. package/resource-workbench/components/ResourceWorkbench.js.map +1 -1
  56. package/resource-workbench/hooks/useResourceCollection.d.ts +8 -0
  57. package/resource-workbench/hooks/useResourceCollection.d.ts.map +1 -1
  58. package/resource-workbench/hooks/useResourceCollection.js +2 -2
  59. package/resource-workbench/hooks/useResourceCollection.js.map +1 -1
  60. package/schedule/CadenceField.d.ts +31 -0
  61. package/schedule/CadenceField.d.ts.map +1 -0
  62. package/schedule/CadenceField.js +116 -0
  63. package/schedule/CadenceField.js.map +1 -0
  64. package/schedule/ScheduleDetailView.d.ts +58 -0
  65. package/schedule/ScheduleDetailView.d.ts.map +1 -0
  66. package/schedule/ScheduleDetailView.js +236 -0
  67. package/schedule/ScheduleDetailView.js.map +1 -0
  68. package/schedule/ScheduleForm.d.ts +43 -0
  69. package/schedule/ScheduleForm.d.ts.map +1 -0
  70. package/schedule/ScheduleForm.js +129 -0
  71. package/schedule/ScheduleForm.js.map +1 -0
  72. package/schedule/ScheduleRowActions.d.ts +20 -0
  73. package/schedule/ScheduleRowActions.d.ts.map +1 -0
  74. package/schedule/ScheduleRowActions.js +54 -0
  75. package/schedule/ScheduleRowActions.js.map +1 -0
  76. package/schedule/TimeZoneField.d.ts +32 -0
  77. package/schedule/TimeZoneField.d.ts.map +1 -0
  78. package/schedule/TimeZoneField.js +104 -0
  79. package/schedule/TimeZoneField.js.map +1 -0
  80. package/schedule/cadence.d.ts +95 -0
  81. package/schedule/cadence.d.ts.map +1 -0
  82. package/schedule/cadence.js +242 -0
  83. package/schedule/cadence.js.map +1 -0
  84. package/schedule/index.d.ts +31 -0
  85. package/schedule/index.d.ts.map +1 -0
  86. package/schedule/index.js +16 -0
  87. package/schedule/index.js.map +1 -0
  88. package/schedule/scheduleColumns.d.ts +26 -0
  89. package/schedule/scheduleColumns.d.ts.map +1 -0
  90. package/schedule/scheduleColumns.js +77 -0
  91. package/schedule/scheduleColumns.js.map +1 -0
  92. package/schedule/scheduleListFn.d.ts +50 -0
  93. package/schedule/scheduleListFn.d.ts.map +1 -0
  94. package/schedule/scheduleListFn.js +65 -0
  95. package/schedule/scheduleListFn.js.map +1 -0
  96. package/schedule/scheduleState.d.ts +43 -0
  97. package/schedule/scheduleState.d.ts.map +1 -0
  98. package/schedule/scheduleState.js +67 -0
  99. package/schedule/scheduleState.js.map +1 -0
  100. package/schedule/useCreateSchedule.d.ts +57 -0
  101. package/schedule/useCreateSchedule.d.ts.map +1 -0
  102. package/schedule/useCreateSchedule.js +61 -0
  103. package/schedule/useCreateSchedule.js.map +1 -0
  104. package/schedule/useResumeSchedule.d.ts +30 -0
  105. package/schedule/useResumeSchedule.d.ts.map +1 -0
  106. package/schedule/useResumeSchedule.js +45 -0
  107. package/schedule/useResumeSchedule.js.map +1 -0
  108. package/schedule/useSchedule.d.ts +39 -0
  109. package/schedule/useSchedule.d.ts.map +1 -0
  110. package/schedule/useSchedule.js +44 -0
  111. package/schedule/useSchedule.js.map +1 -0
  112. package/schedule/useScheduleCount.d.ts +38 -0
  113. package/schedule/useScheduleCount.d.ts.map +1 -0
  114. package/schedule/useScheduleCount.js +30 -0
  115. package/schedule/useScheduleCount.js.map +1 -0
  116. package/schedule/useScheduleList.d.ts +48 -0
  117. package/schedule/useScheduleList.d.ts.map +1 -0
  118. package/schedule/useScheduleList.js +43 -0
  119. package/schedule/useScheduleList.js.map +1 -0
  120. package/schedule/useSetScheduleEnabled.d.ts +31 -0
  121. package/schedule/useSetScheduleEnabled.d.ts.map +1 -0
  122. package/schedule/useSetScheduleEnabled.js +53 -0
  123. package/schedule/useSetScheduleEnabled.js.map +1 -0
  124. package/schedule/useTriggerSchedule.d.ts +28 -0
  125. package/schedule/useTriggerSchedule.d.ts.map +1 -0
  126. package/schedule/useTriggerSchedule.js +43 -0
  127. package/schedule/useTriggerSchedule.js.map +1 -0
  128. package/search/useResourceCount.d.ts +2 -1
  129. package/search/useResourceCount.d.ts.map +1 -1
  130. package/search/useResourceCount.js +6 -1
  131. package/search/useResourceCount.js.map +1 -1
  132. package/src/agent/AgentPicker.tsx +13 -1
  133. package/src/channel/AgentChannelsPanel.tsx +43 -1
  134. package/src/channel/ChannelTemplatesDialog.tsx +433 -0
  135. package/src/channel/__tests__/AgentChannelsPanel.test.tsx +73 -0
  136. package/src/channel/__tests__/ChannelTemplatesDialog.test.tsx +292 -0
  137. package/src/channel/__tests__/providers.test.ts +9 -0
  138. package/src/channel/__tests__/templatePresentation.test.ts +96 -0
  139. package/src/channel/__tests__/useChannelTemplateList.test.tsx +216 -0
  140. package/src/channel/__tests__/useChannelTemplateReadiness.test.tsx +124 -0
  141. package/src/channel/index.ts +15 -0
  142. package/src/channel/providers.ts +26 -2
  143. package/src/channel/templatePresentation.ts +82 -0
  144. package/src/channel/useChannelTemplateList.ts +123 -0
  145. package/src/channel/useChannelTemplateReadiness.ts +69 -0
  146. package/src/dashboard/__tests__/useDashboardFailedRuns.test.tsx +106 -0
  147. package/src/dashboard/useDashboardFailedRuns.ts +2 -0
  148. package/src/index.ts +65 -0
  149. package/src/library/useExportResource.ts +4 -3
  150. package/src/organization/OrgProvider.tsx +29 -4
  151. package/src/organization/__tests__/OrgProvider.orgSwitch.test.tsx +89 -0
  152. package/src/resource-detail/useDeleteResource.ts +11 -1
  153. package/src/resource-workbench/__tests__/ResourceWorkbench.test.tsx +112 -0
  154. package/src/resource-workbench/components/ResourceWorkbench.tsx +32 -15
  155. package/src/resource-workbench/hooks/useResourceCollection.ts +11 -1
  156. package/src/schedule/CadenceField.tsx +365 -0
  157. package/src/schedule/ScheduleDetailView.tsx +583 -0
  158. package/src/schedule/ScheduleForm.tsx +405 -0
  159. package/src/schedule/ScheduleRowActions.tsx +124 -0
  160. package/src/schedule/TimeZoneField.tsx +199 -0
  161. package/src/schedule/__tests__/ScheduleDetailView.test.tsx +260 -0
  162. package/src/schedule/__tests__/cadence.test.ts +278 -0
  163. package/src/schedule/__tests__/scheduleColumnsRowActions.test.tsx +176 -0
  164. package/src/schedule/__tests__/scheduleCreation.test.tsx +340 -0
  165. package/src/schedule/__tests__/scheduleHooks.test.tsx +312 -0
  166. package/src/schedule/__tests__/scheduleListFn.test.ts +128 -0
  167. package/src/schedule/__tests__/scheduleState.test.ts +133 -0
  168. package/src/schedule/cadence.ts +298 -0
  169. package/src/schedule/index.ts +57 -0
  170. package/src/schedule/scheduleColumns.tsx +118 -0
  171. package/src/schedule/scheduleListFn.ts +108 -0
  172. package/src/schedule/scheduleState.ts +96 -0
  173. package/src/schedule/useCreateSchedule.ts +88 -0
  174. package/src/schedule/useResumeSchedule.ts +67 -0
  175. package/src/schedule/useSchedule.ts +67 -0
  176. package/src/schedule/useScheduleCount.ts +57 -0
  177. package/src/schedule/useScheduleList.ts +83 -0
  178. package/src/schedule/useSetScheduleEnabled.ts +84 -0
  179. package/src/schedule/useTriggerSchedule.ts +65 -0
  180. package/src/search/__tests__/useResourceCount.test.tsx +89 -0
  181. package/src/search/useResourceCount.ts +8 -2
  182. package/src/workflow/WorkflowDashboard.tsx +1 -0
  183. package/src/workflow/useWorkflowExecutionList.ts +13 -2
  184. package/styles.css +1 -1
  185. package/workflow/CanvasTransitionEdge.d.ts +1 -1
  186. package/workflow/WorkflowDashboard.d.ts.map +1 -1
  187. package/workflow/WorkflowDashboard.js +1 -0
  188. package/workflow/WorkflowDashboard.js.map +1 -1
  189. package/workflow/useWorkflowExecutionList.d.ts +10 -0
  190. package/workflow/useWorkflowExecutionList.d.ts.map +1 -1
  191. package/workflow/useWorkflowExecutionList.js +3 -2
  192. package/workflow/useWorkflowExecutionList.js.map +1 -1
@@ -0,0 +1,199 @@
1
+ "use client";
2
+
3
+ import {
4
+ useCallback,
5
+ useMemo,
6
+ useRef,
7
+ useState,
8
+ type KeyboardEvent,
9
+ } from "react";
10
+ import { cn } from "@stigmer/theme";
11
+
12
+ /** Props for {@link TimeZoneField}. */
13
+ export interface TimeZoneFieldProps {
14
+ /** Element ID, for association with an external `<label htmlFor>`. */
15
+ readonly id?: string;
16
+ /** The selected IANA time zone name. */
17
+ readonly value: string;
18
+ /** Called with the new zone when the user picks one. */
19
+ readonly onChange: (timeZone: string) => void;
20
+ /** Prevents interaction when `true`. */
21
+ readonly disabled?: boolean;
22
+ }
23
+
24
+ const LIST_ID = "stgm-timezone-list";
25
+ const MAX_VISIBLE = 100;
26
+
27
+ /**
28
+ * Searchable IANA time zone selector for the schedule form.
29
+ *
30
+ * The IANA database has ~400 zones — far too many for a plain
31
+ * `<select>` — so this follows the SDK's searchable-picker idiom
32
+ * (search input + listbox, as in `AgentPicker`): the input shows the
33
+ * selected zone; typing filters the list; Enter or click selects.
34
+ *
35
+ * The zone list comes from the runtime's own tz database
36
+ * (`Intl.supportedValuesOf`), which is also what {@link validateTimeZone}
37
+ * checks against — the offered choices and the accepted choices cannot
38
+ * drift.
39
+ *
40
+ * Internal to the schedule folder by design: public API surface is a
41
+ * contract, and this earns a barrel export only when a second consumer
42
+ * appears.
43
+ */
44
+ export function TimeZoneField({
45
+ id,
46
+ value,
47
+ onChange,
48
+ disabled,
49
+ }: TimeZoneFieldProps) {
50
+ const zones = useMemo(() => allTimeZones(), []);
51
+
52
+ const [query, setQuery] = useState<string | null>(null);
53
+ const [focusIndex, setFocusIndex] = useState(-1);
54
+ const listRef = useRef<HTMLDivElement>(null);
55
+
56
+ const isOpen = query !== null;
57
+
58
+ const filtered = useMemo(() => {
59
+ if (query === null) return [];
60
+ const needle = query.trim().toLowerCase().replace(/\s+/g, "_");
61
+ const matches = needle
62
+ ? zones.filter((z) => z.toLowerCase().includes(needle))
63
+ : zones;
64
+ return matches.slice(0, MAX_VISIBLE);
65
+ }, [zones, query]);
66
+
67
+ const close = useCallback(() => {
68
+ setQuery(null);
69
+ setFocusIndex(-1);
70
+ }, []);
71
+
72
+ const select = useCallback(
73
+ (zone: string) => {
74
+ onChange(zone);
75
+ close();
76
+ },
77
+ [onChange, close],
78
+ );
79
+
80
+ const handleKeyDown = useCallback(
81
+ (e: KeyboardEvent<HTMLInputElement>) => {
82
+ if (e.key === "Escape") {
83
+ e.preventDefault();
84
+ close();
85
+ return;
86
+ }
87
+ if (!isOpen) return;
88
+ if (e.key === "ArrowDown") {
89
+ e.preventDefault();
90
+ setFocusIndex((prev) => (prev < filtered.length - 1 ? prev + 1 : prev));
91
+ } else if (e.key === "ArrowUp") {
92
+ e.preventDefault();
93
+ setFocusIndex((prev) => (prev > 0 ? prev - 1 : -1));
94
+ } else if (e.key === "Enter") {
95
+ e.preventDefault();
96
+ if (focusIndex >= 0 && focusIndex < filtered.length) {
97
+ select(filtered[focusIndex]);
98
+ } else if (filtered.length === 1) {
99
+ select(filtered[0]);
100
+ }
101
+ }
102
+ },
103
+ [isOpen, filtered, focusIndex, select, close],
104
+ );
105
+
106
+ return (
107
+ <div className="relative">
108
+ <input
109
+ id={id}
110
+ type="text"
111
+ role="combobox"
112
+ aria-expanded={isOpen}
113
+ aria-controls={LIST_ID}
114
+ aria-activedescendant={
115
+ focusIndex >= 0 ? `stgm-timezone-${focusIndex}` : undefined
116
+ }
117
+ aria-label="Time zone"
118
+ placeholder="Search time zones…"
119
+ value={query ?? value}
120
+ onChange={(e) => {
121
+ setQuery(e.target.value);
122
+ setFocusIndex(-1);
123
+ }}
124
+ onFocus={() => setQuery("")}
125
+ // Closing on blur reverts the input to the committed value —
126
+ // free-typed text is never submitted, only list selections.
127
+ onBlur={close}
128
+ onKeyDown={handleKeyDown}
129
+ disabled={disabled}
130
+ className={cn(
131
+ "w-full rounded-md border border-input bg-background px-2.5 py-1.5 text-xs text-foreground",
132
+ "placeholder:text-muted-foreground",
133
+ "focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
134
+ "disabled:pointer-events-none disabled:opacity-50",
135
+ )}
136
+ />
137
+
138
+ {isOpen && (
139
+ <div
140
+ ref={listRef}
141
+ id={LIST_ID}
142
+ role="listbox"
143
+ aria-label="Time zones"
144
+ className={cn(
145
+ "absolute z-popover mt-1 max-h-52 w-full overflow-y-auto rounded-md border border-border",
146
+ "bg-popover p-1 text-popover-foreground shadow-md",
147
+ )}
148
+ >
149
+ {filtered.length === 0 ? (
150
+ <div className="py-3 text-center text-xs text-muted-foreground">
151
+ No time zones match
152
+ </div>
153
+ ) : (
154
+ filtered.map((zone, idx) => (
155
+ <button
156
+ key={zone}
157
+ id={`stgm-timezone-${idx}`}
158
+ type="button"
159
+ role="option"
160
+ aria-selected={zone === value}
161
+ // Runs before the input's blur, so selection wins over
162
+ // the close-on-blur revert.
163
+ onMouseDown={(e) => {
164
+ e.preventDefault();
165
+ select(zone);
166
+ }}
167
+ className={cn(
168
+ "flex w-full items-center rounded-sm px-2 py-1.5 text-left text-xs transition-colors",
169
+ idx === focusIndex
170
+ ? "bg-accent text-foreground"
171
+ : "text-popover-foreground hover:bg-accent-hover",
172
+ zone === value && "font-medium",
173
+ )}
174
+ >
175
+ {zone}
176
+ </button>
177
+ ))
178
+ )}
179
+ </div>
180
+ )}
181
+ </div>
182
+ );
183
+ }
184
+
185
+ function allTimeZones(): readonly string[] {
186
+ try {
187
+ return Intl.supportedValuesOf("timeZone");
188
+ } catch {
189
+ // Pre-2022 runtimes without supportedValuesOf: offer the browser's
190
+ // own zone plus UTC rather than an empty list.
191
+ const own = Intl.DateTimeFormat().resolvedOptions().timeZone;
192
+ return own && own !== "UTC" ? [own, "UTC"] : ["UTC"];
193
+ }
194
+ }
195
+
196
+ /** The browser's own IANA time zone — the form's default. */
197
+ export function browserTimeZone(): string {
198
+ return Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC";
199
+ }
@@ -0,0 +1,260 @@
1
+ import { describe, it, expect, vi, beforeAll, beforeEach, afterEach } from "vitest";
2
+ import { render, screen, waitFor, fireEvent, cleanup } from "@testing-library/react";
3
+ import type { ReactNode } from "react";
4
+ import { clone, create } from "@bufbuild/protobuf";
5
+ import { timestampFromDate } from "@bufbuild/protobuf/wkt";
6
+ import { ScheduleSchema, type Schedule } from "@stigmer/protos/ai/stigmer/agentic/schedule/v1/api_pb";
7
+ import { ApiResourceKind } from "@stigmer/protos/ai/stigmer/commons/apiresource/apiresourcekind/api_resource_kind_pb";
8
+ import { StigmerContext } from "../../context";
9
+ import { FetchCacheContext } from "../../internal/FetchCacheProvider";
10
+ import { ScheduleDetailView } from "../ScheduleDetailView";
11
+
12
+ vi.mock("../../feedback/toast", () => ({
13
+ toast: Object.assign(vi.fn(), { success: vi.fn(), error: vi.fn() }),
14
+ }));
15
+
16
+ beforeAll(() => {
17
+ // happy-dom does not implement the native dialog show/close methods.
18
+ HTMLDialogElement.prototype.showModal = function showModal() {
19
+ this.open = true;
20
+ };
21
+ HTMLDialogElement.prototype.close = function close() {
22
+ this.open = false;
23
+ };
24
+ });
25
+
26
+ beforeEach(() => vi.clearAllMocks());
27
+ afterEach(cleanup);
28
+
29
+ const NOW = new Date("2026-08-04T10:00:00Z");
30
+
31
+ function makeSchedule(overrides?: {
32
+ enabled?: boolean;
33
+ pausedReason?: string;
34
+ lastExecutionId?: string;
35
+ }): Schedule {
36
+ return create(ScheduleSchema, {
37
+ apiVersion: "agentic.stigmer.ai/v1",
38
+ kind: "Schedule",
39
+ metadata: {
40
+ id: "sch_01example",
41
+ name: "daily-fee-reminders",
42
+ slug: "daily-fee-reminders",
43
+ org: "isc",
44
+ },
45
+ spec: {
46
+ cron: "0 9 * * *",
47
+ timeZone: "Asia/Kolkata",
48
+ enabled: overrides?.enabled ?? true,
49
+ target: {
50
+ case: "agent",
51
+ value: {
52
+ agentRef: { kind: ApiResourceKind.agent, org: "isc", slug: "fee-reminder" },
53
+ message: "Send today's fee reminders.",
54
+ },
55
+ },
56
+ },
57
+ status: {
58
+ nextFireAt: timestampFromDate(new Date(NOW.getTime() + 3 * 3_600_000)),
59
+ lastFireAt: timestampFromDate(new Date(NOW.getTime() - 5 * 60_000)),
60
+ lastExecutionId: overrides?.lastExecutionId ?? "aex_01run",
61
+ consecutiveFailures: overrides?.pausedReason ? 5 : 0,
62
+ pausedReason: overrides?.pausedReason ?? "",
63
+ },
64
+ });
65
+ }
66
+
67
+ interface MockClient {
68
+ schedule: {
69
+ getByReference: ReturnType<typeof vi.fn>;
70
+ resume: ReturnType<typeof vi.fn>;
71
+ trigger: ReturnType<typeof vi.fn>;
72
+ delete: ReturnType<typeof vi.fn>;
73
+ };
74
+ manifest: { apply: ReturnType<typeof vi.fn> };
75
+ }
76
+
77
+ function makeClient(schedule: Schedule): MockClient {
78
+ return {
79
+ schedule: {
80
+ getByReference: vi.fn().mockResolvedValue(schedule),
81
+ resume: vi.fn().mockResolvedValue(schedule),
82
+ trigger: vi.fn().mockResolvedValue(schedule),
83
+ delete: vi.fn().mockResolvedValue(schedule),
84
+ },
85
+ manifest: {
86
+ apply: vi.fn(async (doc: { message: unknown }) => ({
87
+ yamlKind: "Schedule",
88
+ displayName: "Schedule",
89
+ name: "daily-fee-reminders",
90
+ slug: "daily-fee-reminders",
91
+ org: "isc",
92
+ id: "sch_01example",
93
+ message: doc.message,
94
+ })),
95
+ },
96
+ };
97
+ }
98
+
99
+ function renderView(
100
+ client: MockClient,
101
+ props?: Partial<Parameters<typeof ScheduleDetailView>[0]>,
102
+ ) {
103
+ function Wrapper({ children }: { children: ReactNode }) {
104
+ return (
105
+ <FetchCacheContext.Provider value={null}>
106
+ <StigmerContext.Provider value={client as never}>
107
+ {children}
108
+ </StigmerContext.Provider>
109
+ </FetchCacheContext.Provider>
110
+ );
111
+ }
112
+ return render(
113
+ <ScheduleDetailView
114
+ org="isc"
115
+ slug="daily-fee-reminders"
116
+ now={NOW}
117
+ {...props}
118
+ />,
119
+ { wrapper: Wrapper },
120
+ );
121
+ }
122
+
123
+ describe("ScheduleDetailView", () => {
124
+ it("renders definition and status for an active schedule", async () => {
125
+ renderView(makeClient(makeSchedule()));
126
+
127
+ await screen.findByRole("heading", { name: "daily-fee-reminders" });
128
+ expect(screen.getByText("0 9 * * *")).toBeTruthy();
129
+ expect(screen.getByText("Asia/Kolkata")).toBeTruthy();
130
+ expect(screen.getByText("isc/fee-reminder")).toBeTruthy();
131
+ expect(screen.getByText("Send today's fee reminders.")).toBeTruthy();
132
+ expect(screen.getByText("in 3h")).toBeTruthy();
133
+ expect(screen.getByText("5m")).toBeTruthy();
134
+ expect(screen.getByText("aex_01run")).toBeTruthy();
135
+ expect(screen.getByText("Active")).toBeTruthy();
136
+ // Active schedules show no state banner.
137
+ expect(screen.queryByText("Schedule is disabled")).toBeNull();
138
+ expect(screen.queryByText("Paused by the platform")).toBeNull();
139
+ });
140
+
141
+ it("renders the disabled banner with an inline Enable remedy", async () => {
142
+ const client = makeClient(makeSchedule({ enabled: false }));
143
+ renderView(client);
144
+
145
+ await screen.findByText("Schedule is disabled");
146
+ expect(screen.getByText("Disabled")).toBeTruthy();
147
+ // Owner-disabled schedules have no meaningful countdown.
148
+ expect(screen.queryByText("in 3h")).toBeNull();
149
+
150
+ fireEvent.click(screen.getByRole("button", { name: "Enable schedule" }));
151
+
152
+ await waitFor(() => expect(client.manifest.apply).toHaveBeenCalledOnce());
153
+ const doc = client.manifest.apply.mock.calls[0][0] as { message: Schedule };
154
+ expect(doc.message.spec?.enabled).toBe(true);
155
+ });
156
+
157
+ it("renders the paused banner with reason and an inline Resume remedy", async () => {
158
+ const client = makeClient(makeSchedule({ pausedReason: "5 consecutive failures" }));
159
+ renderView(client);
160
+
161
+ await screen.findByText("Paused by the platform");
162
+ expect(screen.getByText(/5 consecutive failures — resuming clears/)).toBeTruthy();
163
+ expect(screen.getByText("Paused")).toBeTruthy();
164
+
165
+ fireEvent.click(screen.getByRole("button", { name: "Resume schedule" }));
166
+
167
+ await waitFor(() =>
168
+ expect(client.schedule.resume).toHaveBeenCalledWith("sch_01example"),
169
+ );
170
+ });
171
+
172
+ it("never collapses disabled and paused: disabled wins, pause stays visible", async () => {
173
+ const client = makeClient(
174
+ makeSchedule({ enabled: false, pausedReason: "5 consecutive failures" }),
175
+ );
176
+ renderView(client);
177
+
178
+ await screen.findByText("Schedule is disabled");
179
+ expect(screen.queryByText("Paused by the platform")).toBeNull();
180
+ // The badge says Disabled, but the platform pause is still explained.
181
+ expect(screen.getByText("Disabled")).toBeTruthy();
182
+ expect(screen.getByText(/also paused by the platform/)).toBeTruthy();
183
+ });
184
+
185
+ it("gates Run now behind a confirmation and refetches after", async () => {
186
+ const client = makeClient(makeSchedule());
187
+ renderView(client);
188
+
189
+ await screen.findByRole("heading", { name: "daily-fee-reminders" });
190
+ fireEvent.click(screen.getByRole("button", { name: "Run now" }));
191
+
192
+ // Confirmation first — nothing fired yet.
193
+ await screen.findByText("Run this schedule now?");
194
+ expect(client.schedule.trigger).not.toHaveBeenCalled();
195
+
196
+ fireEvent.click(screen.getByText("Start run"));
197
+ await waitFor(() =>
198
+ expect(client.schedule.trigger).toHaveBeenCalledWith("sch_01example"),
199
+ );
200
+ // Refetch picks up the new last_execution_id.
201
+ await waitFor(() =>
202
+ expect(client.schedule.getByReference.mock.calls.length).toBeGreaterThan(1),
203
+ );
204
+ });
205
+
206
+ it("disables Run now when the schedule cannot fire", async () => {
207
+ renderView(makeClient(makeSchedule({ enabled: false })));
208
+
209
+ await screen.findByText("Schedule is disabled");
210
+ const runButton = screen.getByRole("button", { name: "Run now" });
211
+ expect((runButton as HTMLButtonElement).disabled).toBe(true);
212
+ });
213
+
214
+ it("navigates through the callback seams (DD-004)", async () => {
215
+ const onNavigateToAgent = vi.fn();
216
+ const onNavigateToExecution = vi.fn();
217
+ renderView(makeClient(makeSchedule()), {
218
+ onNavigateToAgent,
219
+ onNavigateToExecution,
220
+ });
221
+
222
+ await screen.findByRole("heading", { name: "daily-fee-reminders" });
223
+ fireEvent.click(screen.getByRole("button", { name: "isc/fee-reminder" }));
224
+ expect(onNavigateToAgent).toHaveBeenCalledWith("isc", "fee-reminder");
225
+
226
+ fireEvent.click(screen.getByRole("button", { name: "aex_01run" }));
227
+ expect(onNavigateToExecution).toHaveBeenCalledWith("aex_01run");
228
+ });
229
+
230
+ it("renders references as plain text without navigation callbacks", async () => {
231
+ renderView(makeClient(makeSchedule()));
232
+
233
+ await screen.findByRole("heading", { name: "daily-fee-reminders" });
234
+ expect(screen.queryByRole("button", { name: "isc/fee-reminder" })).toBeNull();
235
+ expect(screen.getByText("isc/fee-reminder")).toBeTruthy();
236
+ });
237
+
238
+ it("shows the not-found state for a missing schedule", async () => {
239
+ const client = makeClient(makeSchedule());
240
+ // NOT_FOUND resolves to null via useSchedule's isNotFound handling —
241
+ // simulate the resolved-null directly.
242
+ client.schedule.getByReference.mockResolvedValue(null);
243
+ renderView(client);
244
+
245
+ await screen.findByText("Schedule not found");
246
+ });
247
+
248
+ it("keeps the caller's schedule untouched when toggling (immutability)", async () => {
249
+ const schedule = makeSchedule({ enabled: false });
250
+ const original = clone(ScheduleSchema, schedule);
251
+ const client = makeClient(schedule);
252
+ renderView(client);
253
+
254
+ await screen.findByText("Schedule is disabled");
255
+ fireEvent.click(screen.getByRole("button", { name: "Enable schedule" }));
256
+ await waitFor(() => expect(client.manifest.apply).toHaveBeenCalledOnce());
257
+
258
+ expect(schedule.spec?.enabled).toBe(original.spec?.enabled);
259
+ });
260
+ });