@checkstack/automation-frontend 0.2.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 (35) hide show
  1. package/CHANGELOG.md +664 -0
  2. package/package.json +38 -0
  3. package/src/components/AutomationMenuItems.tsx +37 -0
  4. package/src/editor/ActionEditor.tsx +367 -0
  5. package/src/editor/ActionListEditor.tsx +203 -0
  6. package/src/editor/AddActionDialog.tsx +225 -0
  7. package/src/editor/AutomationDefinitionContext.tsx +37 -0
  8. package/src/editor/AutomationDefinitionEditor.tsx +99 -0
  9. package/src/editor/ConditionEditor.tsx +218 -0
  10. package/src/editor/ConditionsEditor.tsx +89 -0
  11. package/src/editor/ItemPicker.tsx +147 -0
  12. package/src/editor/TriggersEditor.tsx +269 -0
  13. package/src/editor/action-composite-cards.tsx +390 -0
  14. package/src/editor/action-helpers.ts +365 -0
  15. package/src/editor/action-leaf-cards.tsx +426 -0
  16. package/src/editor/editor-validation.test.ts +95 -0
  17. package/src/editor/editor-validation.tsx +200 -0
  18. package/src/editor/registry-context.tsx +192 -0
  19. package/src/editor/template-completion.test.ts +412 -0
  20. package/src/editor/template-completion.ts +664 -0
  21. package/src/editor/template-helpers.test.ts +145 -0
  22. package/src/editor/template-helpers.ts +95 -0
  23. package/src/editor/trigger-helpers.test.ts +58 -0
  24. package/src/editor/trigger-helpers.ts +67 -0
  25. package/src/editor/useConnectionOptionResolvers.ts +80 -0
  26. package/src/editor/yaml-markers.ts +116 -0
  27. package/src/index.tsx +95 -0
  28. package/src/pages/AutomationEditPage.tsx +567 -0
  29. package/src/pages/AutomationListPage.tsx +304 -0
  30. package/src/pages/RunDetailPage.tsx +333 -0
  31. package/src/pages/RunsPage.tsx +233 -0
  32. package/src/pages/TemplatePlaygroundPage.tsx +224 -0
  33. package/src/script-context.test.ts +247 -0
  34. package/src/script-context.ts +218 -0
  35. package/tsconfig.json +29 -0
@@ -0,0 +1,426 @@
1
+ import React from "react";
2
+ import { Link } from "react-router-dom";
3
+ import { resolveRoute } from "@checkstack/common";
4
+ import { integrationRoutes } from "@checkstack/integration-common";
5
+ import {
6
+ DynamicForm,
7
+ Input,
8
+ Label,
9
+ Select,
10
+ SelectContent,
11
+ SelectItem,
12
+ SelectTrigger,
13
+ SelectValue,
14
+ TemplateValueInput,
15
+ Toggle,
16
+ KeyValueEditor,
17
+ customShellEnvVars,
18
+ type ShellEnvVar,
19
+ type TemplateCompletionProvider,
20
+ type VariableNode,
21
+ } from "@checkstack/ui";
22
+ import type {
23
+ ConditionGuardInput,
24
+ ConditionInput,
25
+ DelayInput,
26
+ ProviderAction,
27
+ StopInput,
28
+ VariablesInput,
29
+ WaitForTriggerInput,
30
+ } from "@checkstack/automation-common";
31
+ import { useAutomationRegistry } from "./registry-context";
32
+ import { ItemPicker } from "./ItemPicker";
33
+ import { ConditionEditor } from "./ConditionEditor";
34
+ import { useConnectionOptionResolvers } from "./useConnectionOptionResolvers";
35
+
36
+ /**
37
+ * Provider action body. Picks an action id from `listActions()` then
38
+ * renders the action's `configJsonSchema` via `DynamicForm`. The staged
39
+ * `completionProvider` (template mode) is handed to DynamicForm so every
40
+ * plain string config field (e.g. a log action's `message`) gets inline
41
+ * `{{ … }}` field / comparator / value / filter autocomplete;
42
+ * `templateProperties` still feeds the Monaco-backed multi-type editor
43
+ * fields.
44
+ */
45
+ export const ProviderActionBody: React.FC<{
46
+ value: ProviderAction;
47
+ onChange: (next: ProviderAction) => void;
48
+ templateProperties: Array<{ path: string; type: string; description?: string }>;
49
+ completionProvider: TemplateCompletionProvider;
50
+ /**
51
+ * Monaco `declare const context: …` lib for inline-script action
52
+ * editors (`Run Script (TypeScript)`), so `context.trigger.payload`
53
+ * is typed as the automation's trigger union instead of an untyped
54
+ * default.
55
+ */
56
+ typeDefinitions: string;
57
+ /**
58
+ * `$CHECKSTACK_*` env var names for the shell script editor's `$`
59
+ * autocomplete — the names the backend injects from the run scope.
60
+ */
61
+ shellEnvVars: ShellEnvVar[];
62
+ // Accepted for a uniform body signature, but unused: the action is fixed
63
+ // once created (no switcher), and `DynamicForm` has no read-only mode yet,
64
+ // so there is nothing here to disable.
65
+ disabled?: boolean;
66
+ }> = ({
67
+ value,
68
+ onChange,
69
+ templateProperties,
70
+ completionProvider,
71
+ typeDefinitions,
72
+ shellEnvVars,
73
+ }) => {
74
+ const { actions, loading } = useAutomationRegistry();
75
+ // The shell script action exposes a user-editable `env` field; surface
76
+ // its keys as `$`-completions alongside the run-scope `$CHECKSTACK_*`
77
+ // vars (memoised so the editor's completion provider isn't re-registered
78
+ // on every keystroke).
79
+ const mergedShellEnvVars = React.useMemo(
80
+ // User's own declared `env` keys first (most relevant), then the
81
+ // run-scope `$CHECKSTACK_*` vars — the suggest list orders by insertion.
82
+ () => [...customShellEnvVars(value.config.env), ...shellEnvVars],
83
+ [shellEnvVars, value.config.env],
84
+ );
85
+ // The action is chosen up front in the add-step picker and fixed for the
86
+ // life of the step — to use a different action, add a new step and delete
87
+ // this one. So the card just resolves the registry entry and renders its
88
+ // config; there is no in-card action switcher.
89
+ const selected = actions.find((action) => action.qualifiedId === value.action);
90
+ // Connection-backed actions (Jira / Teams / Webex) declare a
91
+ // `connectionProviderId`; the bridge turns their `x-options-resolver`
92
+ // fields into a live connection picker + cascading provider dropdowns.
93
+ // Non-connection actions get an empty map (no-op) since the id is absent.
94
+ const optionsResolvers = useConnectionOptionResolvers(
95
+ selected?.connectionProviderId,
96
+ );
97
+
98
+ if (!selected) {
99
+ // Registry still loading vs. a genuinely unresolvable action id. The
100
+ // latter can't be fixed in place (no switcher), so point the operator at
101
+ // the add-a-new-step path.
102
+ return loading ? (
103
+ <p className="text-xs italic text-muted-foreground">Loading action…</p>
104
+ ) : (
105
+ <p className="rounded-md border border-destructive/50 bg-destructive/10 px-3 py-2 text-xs text-destructive">
106
+ Unknown action{" "}
107
+ <code className="font-mono">{value.action || "(none)"}</code>. Delete
108
+ this step and add a new one.
109
+ </p>
110
+ );
111
+ }
112
+
113
+ return (
114
+ <div className="space-y-3">
115
+ {selected.consumes && selected.consumes.length > 0 && (
116
+ <p className="text-[10px] text-muted-foreground">
117
+ Consumes:{" "}
118
+ <code className="font-mono">{selected.consumes.join(", ")}</code>
119
+ </p>
120
+ )}
121
+ <div className="space-y-1">
122
+ <span className="block text-[11px] font-medium uppercase tracking-wide text-muted-foreground">
123
+ Configuration
124
+ </span>
125
+ {selected.connectionProviderId && (
126
+ <p className="text-[10px] text-muted-foreground">
127
+ Connections are managed under{" "}
128
+ <Link
129
+ to={resolveRoute(integrationRoutes.routes.list)}
130
+ className="underline underline-offset-2 hover:text-foreground"
131
+ >
132
+ Integrations
133
+ </Link>
134
+ . Create one there if the picker is empty.
135
+ </p>
136
+ )}
137
+ </div>
138
+ <DynamicForm
139
+ schema={selected.configSchema}
140
+ value={value.config}
141
+ onChange={(next) => onChange({ ...value, config: next })}
142
+ optionsResolvers={optionsResolvers}
143
+ templateProperties={templateProperties}
144
+ templateCompletionProvider={completionProvider}
145
+ typeDefinitions={typeDefinitions}
146
+ shellEnvVars={mergedShellEnvVars}
147
+ />
148
+ </div>
149
+ );
150
+ };
151
+
152
+ export const VariablesActionBody: React.FC<{
153
+ value: VariablesInput;
154
+ onChange: (next: VariablesInput) => void;
155
+ templateProperties: Array<{ path: string; type: string; description?: string }>;
156
+ disabled?: boolean;
157
+ }> = ({ value, onChange, templateProperties, disabled }) => {
158
+ const pairs = React.useMemo(
159
+ () =>
160
+ Object.entries(value.variables).map(([key, raw]) => ({
161
+ key,
162
+ value: typeof raw === "string" ? raw : JSON.stringify(raw),
163
+ })),
164
+ [value.variables],
165
+ );
166
+
167
+ const handleChange = (
168
+ next: Array<{ key: string; value: string }>,
169
+ ): void => {
170
+ const record: Record<string, unknown> = {};
171
+ for (const pair of next) {
172
+ if (!pair.key) continue;
173
+ // Try parsing as JSON; fall back to raw string. Lets the operator
174
+ // write either a literal template `"{{ trigger.payload.x }}"` or
175
+ // a structured value like `42` / `true` / `{...}`.
176
+ try {
177
+ record[pair.key] = JSON.parse(pair.value);
178
+ } catch {
179
+ record[pair.key] = pair.value;
180
+ }
181
+ }
182
+ onChange({ ...value, variables: record });
183
+ };
184
+
185
+ return (
186
+ <div className="space-y-1">
187
+ <Label className="text-xs">Variables</Label>
188
+ <KeyValueEditor
189
+ id="vars"
190
+ value={pairs}
191
+ onChange={handleChange}
192
+ keyPlaceholder="name"
193
+ valuePlaceholder='"literal" or {{ template }}'
194
+ templateProperties={templateProperties}
195
+ />
196
+ <p className="text-[10px] text-muted-foreground">
197
+ Values parsed as JSON when possible; otherwise treated as a string
198
+ / template. Disabled state from the parent card is honoured.
199
+ </p>
200
+ {disabled && (
201
+ <p className="text-[10px] italic text-muted-foreground">
202
+ Read-only.
203
+ </p>
204
+ )}
205
+ </div>
206
+ );
207
+ };
208
+
209
+ export const StopActionBody: React.FC<{
210
+ value: StopInput;
211
+ onChange: (next: StopInput) => void;
212
+ disabled?: boolean;
213
+ }> = ({ value, onChange, disabled }) => (
214
+ <div className="space-y-3">
215
+ <div className="space-y-1">
216
+ <Label className="text-xs">Reason</Label>
217
+ <Input
218
+ value={value.stop.reason ?? ""}
219
+ onChange={(event) =>
220
+ onChange({
221
+ ...value,
222
+ stop: { ...value.stop, reason: event.target.value || undefined },
223
+ })
224
+ }
225
+ placeholder="Stopping because…"
226
+ disabled={disabled}
227
+ />
228
+ </div>
229
+ <div className="flex items-center justify-between">
230
+ <Label className="text-xs">Mark run as failed</Label>
231
+ <Toggle
232
+ checked={value.stop.error === true}
233
+ onCheckedChange={(error) =>
234
+ onChange({ ...value, stop: { ...value.stop, error } })
235
+ }
236
+ disabled={disabled}
237
+ />
238
+ </div>
239
+ </div>
240
+ );
241
+
242
+ export const DelayActionBody: React.FC<{
243
+ value: DelayInput;
244
+ onChange: (next: DelayInput) => void;
245
+ completionProvider: TemplateCompletionProvider;
246
+ disabled?: boolean;
247
+ }> = ({ value, onChange, completionProvider, disabled }) => {
248
+ const useTemplate = "template" in value.delay;
249
+
250
+ return (
251
+ <div className="space-y-3">
252
+ <div className="flex items-center justify-between">
253
+ <Label className="text-xs">Source</Label>
254
+ <Select
255
+ value={useTemplate ? "template" : "seconds"}
256
+ onValueChange={(next) => {
257
+ if (next === "seconds") {
258
+ onChange({ ...value, delay: { seconds: 30 } });
259
+ } else {
260
+ onChange({ ...value, delay: { template: "" } });
261
+ }
262
+ }}
263
+ disabled={disabled}
264
+ >
265
+ <SelectTrigger className="h-7 w-32 text-xs">
266
+ <SelectValue />
267
+ </SelectTrigger>
268
+ <SelectContent>
269
+ <SelectItem value="seconds">Seconds</SelectItem>
270
+ <SelectItem value="template">Template</SelectItem>
271
+ </SelectContent>
272
+ </Select>
273
+ </div>
274
+ {useTemplate ? (
275
+ <div className="space-y-1">
276
+ <Label className="text-xs">Template (renders to seconds)</Label>
277
+ <TemplateValueInput
278
+ value={(value.delay as { template: string }).template}
279
+ onChange={(next) =>
280
+ onChange({ ...value, delay: { template: next } })
281
+ }
282
+ placeholder="{{ trigger.payload.delaySeconds }}"
283
+ completionProvider={completionProvider}
284
+ disabled={disabled}
285
+ />
286
+ </div>
287
+ ) : (
288
+ <div className="space-y-1">
289
+ <Label className="text-xs">Seconds</Label>
290
+ <Input
291
+ type="number"
292
+ min={0}
293
+ max={86_400}
294
+ value={(value.delay as { seconds: number }).seconds}
295
+ onChange={(event) =>
296
+ onChange({
297
+ ...value,
298
+ delay: { seconds: Math.max(0, Number(event.target.value)) },
299
+ })
300
+ }
301
+ disabled={disabled}
302
+ />
303
+ </div>
304
+ )}
305
+ </div>
306
+ );
307
+ };
308
+
309
+ export const WaitForTriggerActionBody: React.FC<{
310
+ value: WaitForTriggerInput;
311
+ onChange: (next: WaitForTriggerInput) => void;
312
+ completionProvider: TemplateCompletionProvider;
313
+ disabled?: boolean;
314
+ }> = ({ value, onChange, completionProvider, disabled }) => {
315
+ const { triggers } = useAutomationRegistry();
316
+ const pickerItems = React.useMemo(
317
+ () =>
318
+ triggers.map((trigger) => ({
319
+ id: trigger.qualifiedId,
320
+ label: trigger.displayName,
321
+ description: trigger.description,
322
+ category: trigger.category,
323
+ })),
324
+ [triggers],
325
+ );
326
+
327
+ return (
328
+ <div className="space-y-3">
329
+ <div className="space-y-1">
330
+ <Label className="text-xs">Event to wait for</Label>
331
+ <ItemPicker
332
+ items={pickerItems}
333
+ value={value.wait_for_trigger.event}
334
+ onSelect={(id) =>
335
+ onChange({
336
+ ...value,
337
+ wait_for_trigger: { ...value.wait_for_trigger, event: id },
338
+ })
339
+ }
340
+ placeholder="Pick a trigger event"
341
+ disabled={disabled}
342
+ />
343
+ </div>
344
+ <div className="grid gap-2 sm:grid-cols-2">
345
+ <div className="space-y-1">
346
+ <Label className="text-xs">Filter template</Label>
347
+ <TemplateValueInput
348
+ value={value.wait_for_trigger.filter ?? ""}
349
+ onChange={(next) =>
350
+ onChange({
351
+ ...value,
352
+ wait_for_trigger: {
353
+ ...value.wait_for_trigger,
354
+ filter: next || undefined,
355
+ },
356
+ })
357
+ }
358
+ placeholder="{{ trigger.payload.id == var.targetId }}"
359
+ completionProvider={completionProvider}
360
+ disabled={disabled}
361
+ />
362
+ </div>
363
+ <div className="space-y-1">
364
+ <Label className="text-xs">Timeout (seconds)</Label>
365
+ <Input
366
+ type="number"
367
+ min={1}
368
+ value={value.wait_for_trigger.timeout_seconds ?? ""}
369
+ onChange={(event) =>
370
+ onChange({
371
+ ...value,
372
+ wait_for_trigger: {
373
+ ...value.wait_for_trigger,
374
+ timeout_seconds: event.target.value
375
+ ? Number(event.target.value)
376
+ : undefined,
377
+ },
378
+ })
379
+ }
380
+ disabled={disabled}
381
+ placeholder="No timeout"
382
+ />
383
+ </div>
384
+ </div>
385
+ <div className="space-y-1">
386
+ <Label className="text-xs">Context key (optional)</Label>
387
+ <Input
388
+ value={value.wait_for_trigger.context_key ?? ""}
389
+ onChange={(event) =>
390
+ onChange({
391
+ ...value,
392
+ wait_for_trigger: {
393
+ ...value.wait_for_trigger,
394
+ context_key: event.target.value || undefined,
395
+ },
396
+ })
397
+ }
398
+ placeholder="Defaults to the triggering event's contextKey"
399
+ disabled={disabled}
400
+ className="font-mono text-xs"
401
+ />
402
+ </div>
403
+ </div>
404
+ );
405
+ };
406
+
407
+ export const ConditionGuardActionBody: React.FC<{
408
+ value: ConditionGuardInput;
409
+ onChange: (next: ConditionGuardInput) => void;
410
+ variableNodes: VariableNode[];
411
+ completionProvider: TemplateCompletionProvider;
412
+ disabled?: boolean;
413
+ }> = ({ value, onChange, variableNodes, completionProvider }) => (
414
+ <div className="space-y-1">
415
+ <Label className="text-xs">Condition</Label>
416
+ <ConditionEditor
417
+ value={value.condition}
418
+ onChange={(next: ConditionInput) =>
419
+ onChange({ ...value, condition: next })
420
+ }
421
+ variableNodes={variableNodes}
422
+ completionProvider={completionProvider}
423
+ bare
424
+ />
425
+ </div>
426
+ );
@@ -0,0 +1,95 @@
1
+ /**
2
+ * Tests for the issue-ownership grouping that drives per-card error
3
+ * marking in the visual editor.
4
+ */
5
+ import { describe, expect, it } from "bun:test";
6
+ import {
7
+ actionPathToDefPath,
8
+ defPathKey,
9
+ partitionIssues,
10
+ } from "./editor-validation";
11
+
12
+ describe("actionPathToDefPath", () => {
13
+ it("maps a root action", () => {
14
+ expect(actionPathToDefPath([{ slot: "root", index: 2 }])).toEqual([
15
+ "actions",
16
+ 2,
17
+ ]);
18
+ });
19
+
20
+ it("maps a nested choose-when action", () => {
21
+ expect(
22
+ actionPathToDefPath([
23
+ { slot: "root", index: 0 },
24
+ { slot: "choose-when", whenIndex: 1, index: 3 },
25
+ ]),
26
+ ).toEqual(["actions", 0, "choose", 1, "sequence", 3]);
27
+ });
28
+
29
+ it("maps repeat + sequence nesting", () => {
30
+ expect(
31
+ actionPathToDefPath([
32
+ { slot: "root", index: 0 },
33
+ { slot: "repeat", index: 1 },
34
+ ]),
35
+ ).toEqual(["actions", 0, "repeat", "sequence", 1]);
36
+ });
37
+ });
38
+
39
+ describe("partitionIssues", () => {
40
+ it("attaches a provider config issue to its action's defPath key", () => {
41
+ const { actions } = partitionIssues([
42
+ { path: ["actions", 0, "config", "level"], message: "Invalid enum" },
43
+ ]);
44
+ // Key is the space-joined defPath of the owning action.
45
+ expect(actions.get(defPathKey(["actions", 0]))).toEqual(["config.level: Invalid enum"]);
46
+ });
47
+
48
+ it("attaches a nested action's config to the nested card, not the parent", () => {
49
+ const { actions } = partitionIssues([
50
+ {
51
+ path: ["actions", 0, "choose", 1, "sequence", 2, "config", "x"],
52
+ message: "bad",
53
+ },
54
+ ]);
55
+ expect(actions.get(defPathKey(["actions", 0, "choose", 1, "sequence", 2]))).toEqual([
56
+ "config.x: bad",
57
+ ]);
58
+ expect(actions.has(defPathKey(["actions", 0]))).toBe(false);
59
+ });
60
+
61
+ it("attaches a choose's own `when` to the choose card", () => {
62
+ const { actions } = partitionIssues([
63
+ { path: ["actions", 0, "choose", 0, "when"], message: "required" },
64
+ ]);
65
+ expect(actions.get(defPathKey(["actions", 0]))).toEqual(["choose.0.when: required"]);
66
+ });
67
+
68
+ it("routes trigger + condition issues to their own buckets", () => {
69
+ const { triggers, conditions } = partitionIssues([
70
+ {
71
+ path: ["triggers", 1, "config", "intervalSeconds"],
72
+ message: "too small",
73
+ },
74
+ { path: ["conditions", 0], message: "bad template" },
75
+ ]);
76
+ expect(triggers.get(1)).toEqual(["config.intervalSeconds: too small"]);
77
+ expect(conditions.get(0)).toEqual(["bad template"]);
78
+ });
79
+
80
+ it("collects unattributable top-level issues under `other`", () => {
81
+ const { other } = partitionIssues([
82
+ { path: ["name"], message: "Required" },
83
+ { path: ["max_runs"], message: "Too big" },
84
+ ]);
85
+ expect(other).toEqual(["name: Required", "max_runs: Too big"]);
86
+ });
87
+
88
+ it("groups multiple issues on the same action", () => {
89
+ const { actions } = partitionIssues([
90
+ { path: ["actions", 0, "config", "a"], message: "x" },
91
+ { path: ["actions", 0, "config", "b"], message: "y" },
92
+ ]);
93
+ expect(actions.get(defPathKey(["actions", 0]))).toEqual(["config.a: x", "config.b: y"]);
94
+ });
95
+ });