@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,147 @@
1
+ import React from "react";
2
+ import { ChevronDown, Search } from "lucide-react";
3
+ import {
4
+ Button,
5
+ Input,
6
+ Popover,
7
+ PopoverContent,
8
+ PopoverTrigger,
9
+ } from "@checkstack/ui";
10
+
11
+ export interface PickerItem {
12
+ id: string;
13
+ label: string;
14
+ description?: string;
15
+ category?: string;
16
+ }
17
+
18
+ export interface ItemPickerProps {
19
+ items: PickerItem[];
20
+ value?: string;
21
+ onSelect: (id: string) => void;
22
+ placeholder?: string;
23
+ emptyText?: string;
24
+ disabled?: boolean;
25
+ className?: string;
26
+ }
27
+
28
+ /**
29
+ * Type-as-you-go combobox for selecting an item from a registered list
30
+ * (action ids, trigger event ids, artifact types). Composes
31
+ * `<Popover>` + `<Input>` + a filtered list — Radix Select doesn't do
32
+ * substring filtering, only jump-to-letter, so we roll our own.
33
+ *
34
+ * Groups items by `category` when set; otherwise renders a flat list.
35
+ * Selecting an item closes the popover and fires `onSelect(id)`.
36
+ */
37
+ export const ItemPicker: React.FC<ItemPickerProps> = ({
38
+ items,
39
+ value,
40
+ onSelect,
41
+ placeholder = "Select…",
42
+ emptyText = "No matches",
43
+ disabled,
44
+ className,
45
+ }) => {
46
+ const [open, setOpen] = React.useState(false);
47
+ const [query, setQuery] = React.useState("");
48
+
49
+ const selected = items.find((item) => item.id === value);
50
+
51
+ const filtered = React.useMemo(() => {
52
+ const q = query.trim().toLowerCase();
53
+ if (!q) return items;
54
+ return items.filter(
55
+ (item) =>
56
+ item.id.toLowerCase().includes(q) ||
57
+ item.label.toLowerCase().includes(q) ||
58
+ item.description?.toLowerCase().includes(q),
59
+ );
60
+ }, [items, query]);
61
+
62
+ const grouped = React.useMemo(() => {
63
+ const groups = new Map<string, PickerItem[]>();
64
+ for (const item of filtered) {
65
+ const key = item.category ?? "";
66
+ const list = groups.get(key) ?? [];
67
+ list.push(item);
68
+ groups.set(key, list);
69
+ }
70
+ return [...groups.entries()];
71
+ }, [filtered]);
72
+
73
+ return (
74
+ <Popover open={open} onOpenChange={setOpen}>
75
+ <PopoverTrigger asChild>
76
+ <Button
77
+ type="button"
78
+ variant="outline"
79
+ disabled={disabled}
80
+ className={`w-full justify-between font-normal ${className ?? ""}`.trim()}
81
+ >
82
+ <span className={selected ? "" : "text-muted-foreground"}>
83
+ {selected ? selected.label : placeholder}
84
+ </span>
85
+ <ChevronDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
86
+ </Button>
87
+ </PopoverTrigger>
88
+ <PopoverContent className="w-96 p-0" align="start">
89
+ <div className="border-b border-border p-2">
90
+ <div className="relative">
91
+ <Search className="absolute left-2 top-1/2 h-3 w-3 -translate-y-1/2 text-muted-foreground" />
92
+ <Input
93
+ autoFocus
94
+ value={query}
95
+ onChange={(event) => setQuery(event.target.value)}
96
+ placeholder="Filter…"
97
+ className="h-7 pl-7 text-xs"
98
+ />
99
+ </div>
100
+ </div>
101
+ <div className="max-h-80 overflow-y-auto py-1">
102
+ {filtered.length === 0 ? (
103
+ <p className="px-3 py-4 text-center text-xs italic text-muted-foreground">
104
+ {emptyText}
105
+ </p>
106
+ ) : (
107
+ grouped.map(([category, list]) => (
108
+ <div key={category || "default"}>
109
+ {category && (
110
+ <p className="px-2 pt-1 text-[10px] uppercase tracking-wide text-muted-foreground">
111
+ {category}
112
+ </p>
113
+ )}
114
+ {list.map((item) => (
115
+ <button
116
+ key={item.id}
117
+ type="button"
118
+ onClick={() => {
119
+ onSelect(item.id);
120
+ setOpen(false);
121
+ setQuery("");
122
+ }}
123
+ className={`flex w-full flex-col gap-0.5 px-3 py-1.5 text-left text-xs hover:bg-accent hover:text-accent-foreground ${
124
+ item.id === value ? "bg-accent/50" : ""
125
+ }`}
126
+ >
127
+ <span className="flex items-center gap-2">
128
+ <span className="font-medium">{item.label}</span>
129
+ <code className="font-mono text-[10px] text-muted-foreground">
130
+ {item.id}
131
+ </code>
132
+ </span>
133
+ {item.description && (
134
+ <span className="text-[10px] text-muted-foreground">
135
+ {item.description}
136
+ </span>
137
+ )}
138
+ </button>
139
+ ))}
140
+ </div>
141
+ ))
142
+ )}
143
+ </div>
144
+ </PopoverContent>
145
+ </Popover>
146
+ );
147
+ };
@@ -0,0 +1,269 @@
1
+ import React from "react";
2
+ import { Plus, Trash2 } from "lucide-react";
3
+ import {
4
+ Button,
5
+ Card,
6
+ CardContent,
7
+ CardHeader,
8
+ CardTitle,
9
+ Input,
10
+ Label,
11
+ DynamicForm,
12
+ TemplateValueInput,
13
+ Badge,
14
+ } from "@checkstack/ui";
15
+ import type {
16
+ AutomationDefinition,
17
+ Trigger,
18
+ } from "@checkstack/automation-common";
19
+ import { useAutomationRegistry, useVariableScope } from "./registry-context";
20
+ import { ItemPicker } from "./ItemPicker";
21
+ import { useTriggerIssues } from "./editor-validation";
22
+ import { collectTriggerIds, defaultTriggerId } from "./trigger-helpers";
23
+
24
+ /**
25
+ * Build a minimal `AutomationDefinition` that only subscribes to the
26
+ * given trigger and has no actions — used to feed `useVariableScope`
27
+ * for a trigger's `filter:` field, where the in-scope variables are
28
+ * just this specific trigger's payload (no upstream actions exist
29
+ * yet, no other triggers are relevant because the filter runs
30
+ * per-trigger). Memoised in the consumer so the resolver's `useMemo`
31
+ * dep array stays stable across re-renders.
32
+ */
33
+ function buildTriggerFilterDefinition(triggerEvent: string): AutomationDefinition {
34
+ return {
35
+ name: "_",
36
+ triggers: [{ event: triggerEvent }],
37
+ conditions: [],
38
+ actions: [],
39
+ mode: "single",
40
+ max_runs: 1,
41
+ };
42
+ }
43
+
44
+ export interface TriggersEditorProps {
45
+ value: Trigger[];
46
+ onChange: (next: Trigger[]) => void;
47
+ disabled?: boolean;
48
+ }
49
+
50
+ /**
51
+ * Editor for the automation's `triggers` array. Each trigger card has:
52
+ *
53
+ * - Event picker (combobox over `listTriggers()` from the registry).
54
+ * - Optional operator-assigned `id` field — used as a discriminator
55
+ * in `choose: when: trigger.id == "x"` expressions.
56
+ * - Optional `filter` template that gates the trigger before any
57
+ * action runs.
58
+ * - When the selected trigger declares a `configSchema`, a
59
+ * DynamicForm renders the per-trigger configuration (e.g.
60
+ * `cronPattern` for `automation.cron`, `intervalSeconds` for
61
+ * `automation.interval`).
62
+ *
63
+ * The triggers list itself isn't drag-reorderable — order doesn't
64
+ * affect runtime behaviour for triggers (any matching trigger fires
65
+ * the automation), and a static list keeps the UI calmer.
66
+ */
67
+ export const TriggersEditor: React.FC<TriggersEditorProps> = ({
68
+ value,
69
+ onChange,
70
+ disabled,
71
+ }) => {
72
+ const { triggers } = useAutomationRegistry();
73
+ const pickerItems = React.useMemo(
74
+ () =>
75
+ triggers.map((t) => ({
76
+ id: t.qualifiedId,
77
+ label: t.displayName,
78
+ description: t.description,
79
+ category: t.category,
80
+ })),
81
+ [triggers],
82
+ );
83
+
84
+ const handleAdd = () => {
85
+ // Assign a unique default id up front (deduped against existing triggers)
86
+ // so the new trigger is immediately referenceable as `trigger.id` and the
87
+ // field shows a value rather than appearing blank.
88
+ const fresh: Trigger = { event: triggers[0]?.qualifiedId ?? "" };
89
+ const id = defaultTriggerId(fresh, collectTriggerIds(value));
90
+ onChange([...value, { ...fresh, id }]);
91
+ };
92
+
93
+ return (
94
+ <Card>
95
+ <CardHeader className="border-b">
96
+ <div className="flex items-center justify-between">
97
+ <CardTitle className="text-base">Triggers</CardTitle>
98
+ <Button
99
+ type="button"
100
+ variant="outline"
101
+ size="sm"
102
+ onClick={handleAdd}
103
+ disabled={disabled}
104
+ >
105
+ <Plus className="mr-1 h-3 w-3" />
106
+ Add trigger
107
+ </Button>
108
+ </div>
109
+ </CardHeader>
110
+ <CardContent className="space-y-2 p-3">
111
+ {value.length === 0 && (
112
+ <p className="text-xs italic text-muted-foreground">
113
+ An automation needs at least one trigger.
114
+ </p>
115
+ )}
116
+ {value.map((trigger, index) => (
117
+ <TriggerCard
118
+ key={index}
119
+ index={index}
120
+ value={trigger}
121
+ onChange={(next) => {
122
+ const list = [...value];
123
+ list[index] = next;
124
+ onChange(list);
125
+ }}
126
+ onRemove={() => onChange(value.filter((_, i) => i !== index))}
127
+ disabled={disabled}
128
+ pickerItems={pickerItems}
129
+ // Ids of the other triggers — used to keep this trigger's
130
+ // auto-filled id unique when the operator clears the field.
131
+ siblingIds={collectTriggerIds(value.filter((_, i) => i !== index))}
132
+ />
133
+ ))}
134
+ </CardContent>
135
+ </Card>
136
+ );
137
+ };
138
+
139
+ const TriggerCard: React.FC<{
140
+ index: number;
141
+ value: Trigger;
142
+ onChange: (next: Trigger) => void;
143
+ onRemove: () => void;
144
+ disabled?: boolean;
145
+ pickerItems: Array<{ id: string; label: string; description?: string; category?: string }>;
146
+ siblingIds: Set<string>;
147
+ }> = ({ index, value, onChange, onRemove, disabled, pickerItems, siblingIds }) => {
148
+ const { triggers } = useAutomationRegistry();
149
+ const selected = triggers.find((t) => t.qualifiedId === value.event);
150
+ const issues = useTriggerIssues(index);
151
+
152
+ // Templates inside the filter / config see only the selected
153
+ // trigger's payload — there are no other triggers, no upstream
154
+ // actions, and no variables in scope at filter-evaluation time.
155
+ const filterScopeDefinition = React.useMemo(
156
+ () => buildTriggerFilterDefinition(value.event),
157
+ [value.event],
158
+ );
159
+ const { templateCompletion } = useVariableScope({
160
+ definition: filterScopeDefinition,
161
+ path: [{ slot: "root", index: 0 }],
162
+ });
163
+
164
+ return (
165
+ <Card
166
+ className={
167
+ issues.length > 0
168
+ ? "border-destructive/60 bg-muted/30 ring-1 ring-destructive/30"
169
+ : "border-border/60 bg-muted/30"
170
+ }
171
+ >
172
+ <CardContent className="space-y-3 p-3">
173
+ {issues.length > 0 && (
174
+ <ul className="space-y-0.5">
175
+ {issues.map((issue, i) => (
176
+ <li key={i} className="text-[11px] font-mono text-destructive">
177
+ {issue}
178
+ </li>
179
+ ))}
180
+ </ul>
181
+ )}
182
+ <div className="flex items-start gap-2">
183
+ <div className="flex-1 space-y-3">
184
+ <div className="space-y-1">
185
+ <Label className="text-xs">Event</Label>
186
+ <ItemPicker
187
+ items={pickerItems}
188
+ value={value.event}
189
+ onSelect={(id) => onChange({ ...value, event: id })}
190
+ placeholder="Pick a trigger event"
191
+ disabled={disabled}
192
+ />
193
+ {selected && (
194
+ <div className="flex items-center gap-2 text-[10px] text-muted-foreground">
195
+ <Badge variant="outline" className="text-[10px]">
196
+ {selected.ownerPluginId}
197
+ </Badge>
198
+ {selected.description}
199
+ </div>
200
+ )}
201
+ </div>
202
+ <div className="grid gap-2 sm:grid-cols-2">
203
+ <div className="space-y-1">
204
+ <Label className="text-xs" htmlFor={`trigger-id-${index}`}>
205
+ ID
206
+ </Label>
207
+ <Input
208
+ id={`trigger-id-${index}`}
209
+ value={value.id ?? ""}
210
+ onChange={(event) =>
211
+ onChange({
212
+ ...value,
213
+ id: event.target.value || undefined,
214
+ })
215
+ }
216
+ onBlur={() => {
217
+ // Never leave the id blank: re-fill a unique default so the
218
+ // trigger stays referenceable as `trigger.id` and is
219
+ // distinguishable from sibling triggers.
220
+ if (value.id) return;
221
+ onChange({ ...value, id: defaultTriggerId(value, siblingIds) });
222
+ }}
223
+ placeholder="Generated on blur"
224
+ disabled={disabled}
225
+ className="font-mono text-xs"
226
+ />
227
+ </div>
228
+ <div className="space-y-1">
229
+ <Label className="text-xs">Filter template</Label>
230
+ <TemplateValueInput
231
+ value={value.filter ?? ""}
232
+ onChange={(next) =>
233
+ onChange({ ...value, filter: next || undefined })
234
+ }
235
+ placeholder="{{ trigger.payload.severity == &quot;high&quot; }}"
236
+ completionProvider={templateCompletion}
237
+ disabled={disabled}
238
+ />
239
+ </div>
240
+ </div>
241
+ {selected?.configSchema && (
242
+ <div className="space-y-1">
243
+ <Label className="text-xs">Trigger configuration</Label>
244
+ <DynamicForm
245
+ schema={selected.configSchema}
246
+ value={value.config ?? {}}
247
+ onChange={(next) =>
248
+ onChange({ ...value, config: next })
249
+ }
250
+ />
251
+ </div>
252
+ )}
253
+ </div>
254
+ <Button
255
+ type="button"
256
+ variant="ghost"
257
+ size="icon"
258
+ className="h-7 w-7 text-destructive hover:bg-destructive/10"
259
+ onClick={onRemove}
260
+ disabled={disabled}
261
+ aria-label="Remove trigger"
262
+ >
263
+ <Trash2 className="h-3 w-3" />
264
+ </Button>
265
+ </div>
266
+ </CardContent>
267
+ </Card>
268
+ );
269
+ };