@checkstack/automation-frontend 0.2.0 → 0.3.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/CHANGELOG.md +352 -0
- package/package.json +13 -9
- package/src/components/AutomationGroupCombobox.tsx +133 -0
- package/src/editor/ActionEditor.tsx +180 -90
- package/src/editor/ActionListEditor.tsx +27 -1
- package/src/editor/AddActionDialog.tsx +15 -45
- package/src/editor/AddConditionDialog.tsx +86 -0
- package/src/editor/AddTriggerDialog.tsx +97 -0
- package/src/editor/AutomationDefinitionEditor.tsx +41 -2
- package/src/editor/ConditionEditor.tsx +359 -70
- package/src/editor/ConditionsEditor.tsx +113 -44
- package/src/editor/ItemSheet.tsx +51 -0
- package/src/editor/RunReplayPicker.tsx +97 -0
- package/src/editor/ScriptServicesBooter.tsx +53 -0
- package/src/editor/ScriptTestRenderer.tsx +150 -0
- package/src/editor/SystemEntityPicker.test.ts +37 -0
- package/src/editor/SystemEntityPicker.tsx +109 -0
- package/src/editor/TriggersEditor.tsx +345 -137
- package/src/editor/action-helpers.test.ts +107 -0
- package/src/editor/action-helpers.ts +72 -0
- package/src/editor/action-leaf-cards.tsx +98 -1
- package/src/editor/condition-kind.test.ts +126 -0
- package/src/editor/condition-kind.ts +130 -0
- package/src/editor/item-summary.test.ts +171 -0
- package/src/editor/item-summary.ts +210 -0
- package/src/editor/picker-dialog.tsx +156 -0
- package/src/editor/registry-context.tsx +9 -2
- package/src/editor/script-actions.test.ts +184 -0
- package/src/editor/script-actions.ts +146 -0
- package/src/editor/system-entity-picker.logic.ts +23 -0
- package/src/editor/template-completion.test.ts +22 -3
- package/src/editor/template-completion.ts +16 -8
- package/src/editor/template-helpers.ts +4 -0
- package/src/editor/trigger-helpers.test.ts +28 -0
- package/src/editor/trigger-helpers.ts +17 -0
- package/src/editor/useScriptDiagnostics.ts +108 -0
- package/src/index.tsx +2 -0
- package/src/pages/AutomationEditPage.tsx +95 -47
- package/src/pages/AutomationListPage.tsx +172 -123
- package/src/pages/automation-grouping.test.ts +86 -0
- package/src/pages/automation-grouping.ts +65 -0
- package/src/script-context.test.ts +142 -1
- package/src/script-context.ts +115 -0
- package/tsconfig.json +12 -0
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import React from "react";
|
|
2
|
-
import { Plus, Trash2 } from "lucide-react";
|
|
3
2
|
import {
|
|
4
|
-
|
|
3
|
+
ActionCard,
|
|
4
|
+
type ActionCardMenuItem,
|
|
5
5
|
Card,
|
|
6
6
|
CardContent,
|
|
7
7
|
CardHeader,
|
|
@@ -9,17 +9,36 @@ import {
|
|
|
9
9
|
Input,
|
|
10
10
|
Label,
|
|
11
11
|
DynamicForm,
|
|
12
|
+
DurationInput,
|
|
12
13
|
TemplateValueInput,
|
|
14
|
+
Toggle,
|
|
13
15
|
Badge,
|
|
16
|
+
Select,
|
|
17
|
+
SelectContent,
|
|
18
|
+
SelectItem,
|
|
19
|
+
SelectTrigger,
|
|
20
|
+
SelectValue,
|
|
21
|
+
VariablePicker,
|
|
22
|
+
type DurationValue,
|
|
23
|
+
type TemplateCompletionProvider,
|
|
24
|
+
type VariableNode,
|
|
14
25
|
} from "@checkstack/ui";
|
|
15
26
|
import type {
|
|
16
27
|
AutomationDefinition,
|
|
28
|
+
Duration,
|
|
17
29
|
Trigger,
|
|
30
|
+
Window,
|
|
18
31
|
} from "@checkstack/automation-common";
|
|
19
32
|
import { useAutomationRegistry, useVariableScope } from "./registry-context";
|
|
20
|
-
import {
|
|
33
|
+
import { AddTriggerDialog } from "./AddTriggerDialog";
|
|
34
|
+
import { ItemSheet } from "./ItemSheet";
|
|
21
35
|
import { useTriggerIssues } from "./editor-validation";
|
|
22
|
-
import {
|
|
36
|
+
import {
|
|
37
|
+
collectTriggerIds,
|
|
38
|
+
defaultTriggerId,
|
|
39
|
+
makeTrigger,
|
|
40
|
+
} from "./trigger-helpers";
|
|
41
|
+
import { summarizeTrigger } from "./item-summary";
|
|
23
42
|
|
|
24
43
|
/**
|
|
25
44
|
* Build a minimal `AutomationDefinition` that only subscribes to the
|
|
@@ -37,6 +56,7 @@ function buildTriggerFilterDefinition(triggerEvent: string): AutomationDefinitio
|
|
|
37
56
|
conditions: [],
|
|
38
57
|
actions: [],
|
|
39
58
|
mode: "single",
|
|
59
|
+
concurrency_scope: "automation",
|
|
40
60
|
max_runs: 1,
|
|
41
61
|
};
|
|
42
62
|
}
|
|
@@ -69,43 +89,10 @@ export const TriggersEditor: React.FC<TriggersEditorProps> = ({
|
|
|
69
89
|
onChange,
|
|
70
90
|
disabled,
|
|
71
91
|
}) => {
|
|
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
92
|
return (
|
|
94
93
|
<Card>
|
|
95
94
|
<CardHeader className="border-b">
|
|
96
|
-
<
|
|
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>
|
|
95
|
+
<CardTitle className="text-base">Triggers</CardTitle>
|
|
109
96
|
</CardHeader>
|
|
110
97
|
<CardContent className="space-y-2 p-3">
|
|
111
98
|
{value.length === 0 && (
|
|
@@ -124,13 +111,36 @@ export const TriggersEditor: React.FC<TriggersEditorProps> = ({
|
|
|
124
111
|
onChange(list);
|
|
125
112
|
}}
|
|
126
113
|
onRemove={() => onChange(value.filter((_, i) => i !== index))}
|
|
114
|
+
onDuplicate={() => {
|
|
115
|
+
// Clone the trigger with a fresh, unique id (deduped against
|
|
116
|
+
// every sibling) inserted directly after the original.
|
|
117
|
+
const taken = collectTriggerIds(value);
|
|
118
|
+
const { id: _id, ...rest } = trigger;
|
|
119
|
+
const id = defaultTriggerId(rest, taken);
|
|
120
|
+
onChange([
|
|
121
|
+
...value.slice(0, index + 1),
|
|
122
|
+
{ ...rest, id },
|
|
123
|
+
...value.slice(index + 1),
|
|
124
|
+
]);
|
|
125
|
+
}}
|
|
127
126
|
disabled={disabled}
|
|
128
|
-
pickerItems={pickerItems}
|
|
129
127
|
// Ids of the other triggers — used to keep this trigger's
|
|
130
128
|
// auto-filled id unique when the operator clears the field.
|
|
131
129
|
siblingIds={collectTriggerIds(value.filter((_, i) => i !== index))}
|
|
132
130
|
/>
|
|
133
131
|
))}
|
|
132
|
+
<AddTriggerDialog
|
|
133
|
+
disabled={disabled}
|
|
134
|
+
onAdd={(event) =>
|
|
135
|
+
// Assign a unique default id up front (deduped against existing
|
|
136
|
+
// triggers) so the new trigger is immediately referenceable as
|
|
137
|
+
// `trigger.id` and the field shows a value rather than blank.
|
|
138
|
+
onChange([
|
|
139
|
+
...value,
|
|
140
|
+
makeTrigger({ event, taken: collectTriggerIds(value) }),
|
|
141
|
+
])
|
|
142
|
+
}
|
|
143
|
+
/>
|
|
134
144
|
</CardContent>
|
|
135
145
|
</Card>
|
|
136
146
|
);
|
|
@@ -141,13 +151,22 @@ const TriggerCard: React.FC<{
|
|
|
141
151
|
value: Trigger;
|
|
142
152
|
onChange: (next: Trigger) => void;
|
|
143
153
|
onRemove: () => void;
|
|
154
|
+
onDuplicate: () => void;
|
|
144
155
|
disabled?: boolean;
|
|
145
|
-
pickerItems: Array<{ id: string; label: string; description?: string; category?: string }>;
|
|
146
156
|
siblingIds: Set<string>;
|
|
147
|
-
}> = ({
|
|
157
|
+
}> = ({
|
|
158
|
+
index,
|
|
159
|
+
value,
|
|
160
|
+
onChange,
|
|
161
|
+
onRemove,
|
|
162
|
+
onDuplicate,
|
|
163
|
+
disabled,
|
|
164
|
+
siblingIds,
|
|
165
|
+
}) => {
|
|
148
166
|
const { triggers } = useAutomationRegistry();
|
|
149
167
|
const selected = triggers.find((t) => t.qualifiedId === value.event);
|
|
150
168
|
const issues = useTriggerIssues(index);
|
|
169
|
+
const [sheetOpen, setSheetOpen] = React.useState(false);
|
|
151
170
|
|
|
152
171
|
// Templates inside the filter / config see only the selected
|
|
153
172
|
// trigger's payload — there are no other triggers, no upstream
|
|
@@ -156,114 +175,303 @@ const TriggerCard: React.FC<{
|
|
|
156
175
|
() => buildTriggerFilterDefinition(value.event),
|
|
157
176
|
[value.event],
|
|
158
177
|
);
|
|
159
|
-
const { templateCompletion } =
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
178
|
+
const { templateCompletion, expressionCompletion, variableNodes } =
|
|
179
|
+
useVariableScope({
|
|
180
|
+
definition: filterScopeDefinition,
|
|
181
|
+
path: [{ slot: "root", index: 0 }],
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
// Surface a problem rather than hide it behind a collapsed row + closed
|
|
185
|
+
// sheet.
|
|
186
|
+
React.useEffect(() => {
|
|
187
|
+
if (issues.length > 0) setSheetOpen(true);
|
|
188
|
+
}, [issues.length]);
|
|
189
|
+
|
|
190
|
+
const title = selected?.displayName ?? value.event ?? "Trigger";
|
|
191
|
+
const summary = summarizeTrigger(value);
|
|
192
|
+
|
|
193
|
+
const menuItems: ActionCardMenuItem[] = disabled
|
|
194
|
+
? []
|
|
195
|
+
: [
|
|
196
|
+
{ label: "Duplicate", icon: "Copy", onClick: onDuplicate },
|
|
197
|
+
{
|
|
198
|
+
label: "Delete",
|
|
199
|
+
icon: "Trash2",
|
|
200
|
+
onClick: onRemove,
|
|
201
|
+
variant: "destructive",
|
|
202
|
+
},
|
|
203
|
+
];
|
|
163
204
|
|
|
164
205
|
return (
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
{
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
206
|
+
<>
|
|
207
|
+
<ActionCard
|
|
208
|
+
id={`trigger-${index}`}
|
|
209
|
+
title={title}
|
|
210
|
+
summary={summary}
|
|
211
|
+
category="Trigger"
|
|
212
|
+
icon="Zap"
|
|
213
|
+
onOpenSheet={() => setSheetOpen(true)}
|
|
214
|
+
actions={menuItems}
|
|
215
|
+
errors={issues}
|
|
216
|
+
/>
|
|
217
|
+
<ItemSheet
|
|
218
|
+
open={sheetOpen}
|
|
219
|
+
onOpenChange={setSheetOpen}
|
|
220
|
+
title={title}
|
|
221
|
+
description="Trigger"
|
|
222
|
+
>
|
|
223
|
+
{selected && (
|
|
224
|
+
// Read-only context for the configured trigger — the event/kind is
|
|
225
|
+
// chosen up front in the Add dialog, so it isn't editable here.
|
|
226
|
+
<div className="flex items-center gap-2 text-[10px] text-muted-foreground">
|
|
227
|
+
<Badge variant="outline" className="text-[10px]">
|
|
228
|
+
{selected.ownerPluginId}
|
|
229
|
+
</Badge>
|
|
230
|
+
{selected.description}
|
|
231
|
+
</div>
|
|
232
|
+
)}
|
|
233
|
+
{selected?.configSchema && (
|
|
234
|
+
<div className="space-y-1">
|
|
235
|
+
<Label className="text-xs">Trigger configuration</Label>
|
|
236
|
+
<DynamicForm
|
|
237
|
+
schema={selected.configSchema}
|
|
238
|
+
value={value.config ?? {}}
|
|
239
|
+
onChange={(next) => onChange({ ...value, config: next })}
|
|
240
|
+
/>
|
|
241
|
+
</div>
|
|
181
242
|
)}
|
|
182
|
-
<div className="
|
|
183
|
-
<div className="
|
|
243
|
+
<div className="rounded-lg border border-border/60 bg-muted/20 p-3 space-y-3">
|
|
244
|
+
<div className="grid gap-2 sm:grid-cols-2">
|
|
184
245
|
<div className="space-y-1">
|
|
185
|
-
<Label className="text-xs">
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
246
|
+
<Label className="text-xs" htmlFor={`trigger-id-${index}`}>
|
|
247
|
+
ID
|
|
248
|
+
</Label>
|
|
249
|
+
<Input
|
|
250
|
+
id={`trigger-id-${index}`}
|
|
251
|
+
value={value.id ?? ""}
|
|
252
|
+
onChange={(event) =>
|
|
253
|
+
onChange({
|
|
254
|
+
...value,
|
|
255
|
+
id: event.target.value || undefined,
|
|
256
|
+
})
|
|
257
|
+
}
|
|
258
|
+
onBlur={() => {
|
|
259
|
+
// Never leave the id blank: re-fill a unique default
|
|
260
|
+
// so the trigger stays referenceable as `trigger.id`
|
|
261
|
+
// and is distinguishable from sibling triggers.
|
|
262
|
+
if (value.id) return;
|
|
263
|
+
onChange({
|
|
264
|
+
...value,
|
|
265
|
+
id: defaultTriggerId(value, siblingIds),
|
|
266
|
+
});
|
|
267
|
+
}}
|
|
268
|
+
placeholder="Generated on blur"
|
|
191
269
|
disabled={disabled}
|
|
270
|
+
className="font-mono text-xs"
|
|
192
271
|
/>
|
|
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
272
|
</div>
|
|
202
|
-
<div className="
|
|
203
|
-
<
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
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 == "high" }}"
|
|
236
|
-
completionProvider={templateCompletion}
|
|
237
|
-
disabled={disabled}
|
|
238
|
-
/>
|
|
239
|
-
</div>
|
|
273
|
+
<div className="space-y-1">
|
|
274
|
+
<Label className="text-xs">Filter template</Label>
|
|
275
|
+
<TemplateValueInput
|
|
276
|
+
value={value.filter ?? ""}
|
|
277
|
+
onChange={(next) =>
|
|
278
|
+
onChange({ ...value, filter: next || undefined })
|
|
279
|
+
}
|
|
280
|
+
placeholder="{{ trigger.payload.severity == "high" }}"
|
|
281
|
+
completionProvider={templateCompletion}
|
|
282
|
+
disabled={disabled}
|
|
283
|
+
/>
|
|
240
284
|
</div>
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
onChange
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
285
|
+
</div>
|
|
286
|
+
<div className="space-y-1">
|
|
287
|
+
<div className="flex items-center gap-2">
|
|
288
|
+
<Toggle
|
|
289
|
+
checked={value.for !== undefined}
|
|
290
|
+
onCheckedChange={(checked) =>
|
|
291
|
+
onChange({
|
|
292
|
+
...value,
|
|
293
|
+
for: checked ? { minutes: 30 } : undefined,
|
|
294
|
+
})
|
|
295
|
+
}
|
|
296
|
+
disabled={disabled}
|
|
297
|
+
/>
|
|
298
|
+
<Label className="text-xs">
|
|
299
|
+
Dwell: fire only if the matched state still holds after
|
|
300
|
+
</Label>
|
|
301
|
+
</div>
|
|
302
|
+
{value.for !== undefined && (
|
|
303
|
+
<DurationInput
|
|
304
|
+
value={value.for as DurationValue}
|
|
305
|
+
onChange={(next) =>
|
|
306
|
+
onChange({
|
|
307
|
+
...value,
|
|
308
|
+
for: (next as Duration) ?? undefined,
|
|
309
|
+
})
|
|
310
|
+
}
|
|
311
|
+
disabled={disabled}
|
|
312
|
+
/>
|
|
252
313
|
)}
|
|
253
314
|
</div>
|
|
254
|
-
<
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
315
|
+
<div className="space-y-1">
|
|
316
|
+
<div className="flex items-center gap-2">
|
|
317
|
+
<Toggle
|
|
318
|
+
checked={value.window !== undefined}
|
|
319
|
+
onCheckedChange={(checked) =>
|
|
320
|
+
onChange({
|
|
321
|
+
...value,
|
|
322
|
+
window: checked
|
|
323
|
+
? { count: 3, minutes: 60, refire: "every" }
|
|
324
|
+
: undefined,
|
|
325
|
+
})
|
|
326
|
+
}
|
|
327
|
+
disabled={disabled}
|
|
328
|
+
/>
|
|
329
|
+
<Label className="text-xs">
|
|
330
|
+
Rate: fire only after N occurrences within a window
|
|
331
|
+
</Label>
|
|
332
|
+
</div>
|
|
333
|
+
{value.window !== undefined && (
|
|
334
|
+
<WindowInput
|
|
335
|
+
value={value.window}
|
|
336
|
+
onChange={(next) => onChange({ ...value, window: next })}
|
|
337
|
+
completionProvider={expressionCompletion}
|
|
338
|
+
variableNodes={variableNodes}
|
|
339
|
+
contextKeyLabel={selected?.contextKeyLabel}
|
|
340
|
+
disabled={disabled}
|
|
341
|
+
/>
|
|
342
|
+
)}
|
|
343
|
+
</div>
|
|
344
|
+
</div>
|
|
345
|
+
</ItemSheet>
|
|
346
|
+
</>
|
|
347
|
+
);
|
|
348
|
+
};
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* Editor for a trigger's windowed-count / rate gate. Count + minutes number
|
|
352
|
+
* inputs, a re-fire mode select, and an optional "Partition by" expression
|
|
353
|
+
* that overrides the dimension the count is bucketed by. Matches the
|
|
354
|
+
* surrounding compact editor style.
|
|
355
|
+
*/
|
|
356
|
+
const WindowInput: React.FC<{
|
|
357
|
+
value: Window;
|
|
358
|
+
onChange: (next: Window) => void;
|
|
359
|
+
/**
|
|
360
|
+
* BARE-expression completion provider (`expressionCompletion` from
|
|
361
|
+
* `useVariableScope`) — `partitionBy` is evaluated like a condition (no
|
|
362
|
+
* `{{ }}` wrapper), so it uses the same provider the condition expression
|
|
363
|
+
* field does, NOT the template provider used for `{{ }}` fields.
|
|
364
|
+
*/
|
|
365
|
+
completionProvider: TemplateCompletionProvider;
|
|
366
|
+
/** Hierarchical scope for the "fx" VariablePicker insert affordance. */
|
|
367
|
+
variableNodes: VariableNode[];
|
|
368
|
+
/** Built-in partition dimension label (e.g. "system"); undefined ⇒ per automation. */
|
|
369
|
+
contextKeyLabel?: string;
|
|
370
|
+
disabled?: boolean;
|
|
371
|
+
}> = ({
|
|
372
|
+
value,
|
|
373
|
+
onChange,
|
|
374
|
+
completionProvider,
|
|
375
|
+
variableNodes,
|
|
376
|
+
contextKeyLabel,
|
|
377
|
+
disabled,
|
|
378
|
+
}) => {
|
|
379
|
+
const defaultPartition = contextKeyLabel
|
|
380
|
+
? `per ${contextKeyLabel}`
|
|
381
|
+
: "per automation";
|
|
382
|
+
return (
|
|
383
|
+
<div className="space-y-2">
|
|
384
|
+
<div className="grid grid-cols-3 gap-2">
|
|
385
|
+
<div className="space-y-1">
|
|
386
|
+
<Label className="text-xs">Count</Label>
|
|
387
|
+
<Input
|
|
388
|
+
type="number"
|
|
389
|
+
min={1}
|
|
390
|
+
max={1000}
|
|
391
|
+
value={value.count}
|
|
392
|
+
onChange={(e) => {
|
|
393
|
+
const count = Number.parseInt(e.target.value, 10);
|
|
394
|
+
onChange({ ...value, count: Number.isFinite(count) ? count : 1 });
|
|
395
|
+
}}
|
|
396
|
+
disabled={disabled}
|
|
397
|
+
className="text-xs"
|
|
398
|
+
/>
|
|
399
|
+
</div>
|
|
400
|
+
<div className="space-y-1">
|
|
401
|
+
<Label className="text-xs">Within (minutes)</Label>
|
|
402
|
+
<Input
|
|
403
|
+
type="number"
|
|
404
|
+
min={1}
|
|
405
|
+
max={1440}
|
|
406
|
+
value={value.minutes}
|
|
407
|
+
onChange={(e) => {
|
|
408
|
+
const minutes = Number.parseInt(e.target.value, 10);
|
|
409
|
+
onChange({
|
|
410
|
+
...value,
|
|
411
|
+
minutes: Number.isFinite(minutes) ? minutes : 1,
|
|
412
|
+
});
|
|
413
|
+
}}
|
|
414
|
+
disabled={disabled}
|
|
415
|
+
className="text-xs"
|
|
416
|
+
/>
|
|
417
|
+
</div>
|
|
418
|
+
<div className="space-y-1">
|
|
419
|
+
<Label className="text-xs">Re-fire</Label>
|
|
420
|
+
<Select
|
|
421
|
+
value={value.refire}
|
|
422
|
+
onValueChange={(next) =>
|
|
423
|
+
onChange({ ...value, refire: next === "once" ? "once" : "every" })
|
|
424
|
+
}
|
|
260
425
|
disabled={disabled}
|
|
261
|
-
aria-label="Remove trigger"
|
|
262
426
|
>
|
|
263
|
-
<
|
|
264
|
-
|
|
427
|
+
<SelectTrigger className="text-xs">
|
|
428
|
+
<SelectValue />
|
|
429
|
+
</SelectTrigger>
|
|
430
|
+
<SelectContent>
|
|
431
|
+
<SelectItem value="every">every occurrence</SelectItem>
|
|
432
|
+
<SelectItem value="once">once (crossing edge)</SelectItem>
|
|
433
|
+
</SelectContent>
|
|
434
|
+
</Select>
|
|
265
435
|
</div>
|
|
266
|
-
</
|
|
267
|
-
|
|
436
|
+
</div>
|
|
437
|
+
<div className="space-y-1">
|
|
438
|
+
<div className="flex items-center justify-between gap-2">
|
|
439
|
+
<Label className="text-xs">Partition by</Label>
|
|
440
|
+
{!disabled && (
|
|
441
|
+
<VariablePicker
|
|
442
|
+
scope={variableNodes}
|
|
443
|
+
onSelect={(path) => {
|
|
444
|
+
// partitionBy is a bare expression — insert the raw path, not
|
|
445
|
+
// a `{{ … }}`-wrapped reference (matches the condition editor).
|
|
446
|
+
const before = value.partitionBy ?? "";
|
|
447
|
+
const sep =
|
|
448
|
+
before.length > 0 && !before.endsWith(" ") ? " " : "";
|
|
449
|
+
onChange({ ...value, partitionBy: `${before}${sep}${path}` });
|
|
450
|
+
}}
|
|
451
|
+
/>
|
|
452
|
+
)}
|
|
453
|
+
</div>
|
|
454
|
+
<TemplateValueInput
|
|
455
|
+
value={value.partitionBy ?? ""}
|
|
456
|
+
onChange={(next) =>
|
|
457
|
+
// Store the RAW value (don't `.trim()` on change): trimming strips
|
|
458
|
+
// the trailing space the user must type to reach the operator
|
|
459
|
+
// stage, swallowing it mid-type. Only a blank / whitespace-only
|
|
460
|
+
// value clears the field. The gate trims when it evaluates.
|
|
461
|
+
onChange({
|
|
462
|
+
...value,
|
|
463
|
+
partitionBy: next.trim() === "" ? undefined : next,
|
|
464
|
+
})
|
|
465
|
+
}
|
|
466
|
+
placeholder={`Leave blank to count ${defaultPartition}`}
|
|
467
|
+
completionProvider={completionProvider}
|
|
468
|
+
disabled={disabled}
|
|
469
|
+
/>
|
|
470
|
+
<p className="text-muted-foreground text-xs">
|
|
471
|
+
Bare expression (no <code>{"{{ }}"}</code>) for the key the count is
|
|
472
|
+
bucketed by. Blank counts {defaultPartition}.
|
|
473
|
+
</p>
|
|
474
|
+
</div>
|
|
475
|
+
</div>
|
|
268
476
|
);
|
|
269
477
|
};
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { describe, it, expect } from "bun:test";
|
|
2
|
+
import type {
|
|
3
|
+
ActionInput,
|
|
4
|
+
ChooseInput,
|
|
5
|
+
ParallelInput,
|
|
6
|
+
ProviderAction,
|
|
7
|
+
} from "@checkstack/automation-common";
|
|
8
|
+
import {
|
|
9
|
+
collectActionIds,
|
|
10
|
+
duplicateAction,
|
|
11
|
+
} from "./action-helpers";
|
|
12
|
+
|
|
13
|
+
describe("duplicateAction", () => {
|
|
14
|
+
it("clones a leaf action with a fresh, unique id", () => {
|
|
15
|
+
const original: ProviderAction = {
|
|
16
|
+
action: "automation.log",
|
|
17
|
+
config: { message: "hi" },
|
|
18
|
+
enabled: true,
|
|
19
|
+
continue_on_error: false,
|
|
20
|
+
id: "log",
|
|
21
|
+
};
|
|
22
|
+
const clone = duplicateAction(original, new Set(["log"]));
|
|
23
|
+
expect(clone.id).not.toBe("log");
|
|
24
|
+
expect(clone.id).toBe("log_2");
|
|
25
|
+
// Config is carried over verbatim.
|
|
26
|
+
expect((clone as ProviderAction).config).toEqual({ message: "hi" });
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("does not mutate the original action", () => {
|
|
30
|
+
const original: ProviderAction = {
|
|
31
|
+
action: "automation.log",
|
|
32
|
+
config: {},
|
|
33
|
+
enabled: true,
|
|
34
|
+
continue_on_error: false,
|
|
35
|
+
id: "log",
|
|
36
|
+
};
|
|
37
|
+
const before = structuredClone(original);
|
|
38
|
+
duplicateAction(original, new Set(["log"]));
|
|
39
|
+
expect(original).toEqual(before);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("assigns fresh ids to every nested child of a composite", () => {
|
|
43
|
+
const original: ChooseInput = {
|
|
44
|
+
id: "branch",
|
|
45
|
+
enabled: true,
|
|
46
|
+
continue_on_error: false,
|
|
47
|
+
choose: [
|
|
48
|
+
{
|
|
49
|
+
when: "true",
|
|
50
|
+
sequence: [
|
|
51
|
+
{ action: "automation.log", config: {}, enabled: true, continue_on_error: false, id: "a" },
|
|
52
|
+
],
|
|
53
|
+
},
|
|
54
|
+
],
|
|
55
|
+
else: [
|
|
56
|
+
{ action: "automation.log", config: {}, enabled: true, continue_on_error: false, id: "b" },
|
|
57
|
+
],
|
|
58
|
+
};
|
|
59
|
+
const taken = collectActionIds([original]);
|
|
60
|
+
const clone = duplicateAction(original, taken);
|
|
61
|
+
|
|
62
|
+
const cloneIds = collectActionIds([clone]);
|
|
63
|
+
const originalIds = collectActionIds([original]);
|
|
64
|
+
// No id from the clone collides with any id in the original tree.
|
|
65
|
+
for (const id of cloneIds) {
|
|
66
|
+
expect(originalIds.has(id)).toBe(false);
|
|
67
|
+
}
|
|
68
|
+
// Every nested step still carries an id (none left blank).
|
|
69
|
+
const choose = clone as ChooseInput;
|
|
70
|
+
expect(choose.choose[0]!.sequence[0]!.id).toBeTruthy();
|
|
71
|
+
expect(choose.else![0]!.id).toBeTruthy();
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("keeps ids unique across repeated duplication against one set", () => {
|
|
75
|
+
const original: ProviderAction = {
|
|
76
|
+
action: "automation.log",
|
|
77
|
+
config: {},
|
|
78
|
+
enabled: true,
|
|
79
|
+
continue_on_error: false,
|
|
80
|
+
id: "log",
|
|
81
|
+
};
|
|
82
|
+
const taken = collectActionIds([original]);
|
|
83
|
+
const first = duplicateAction(original, taken);
|
|
84
|
+
const second = duplicateAction(original, taken);
|
|
85
|
+
expect(first.id).not.toBe(second.id);
|
|
86
|
+
expect(taken.has(first.id!)).toBe(true);
|
|
87
|
+
expect(taken.has(second.id!)).toBe(true);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("dedupes nested parallel children against the whole automation", () => {
|
|
91
|
+
const original: ParallelInput = {
|
|
92
|
+
id: "par",
|
|
93
|
+
enabled: true,
|
|
94
|
+
continue_on_error: false,
|
|
95
|
+
parallel: [
|
|
96
|
+
{ action: "automation.log", config: {}, enabled: true, continue_on_error: false, id: "x" },
|
|
97
|
+
{ action: "automation.log", config: {}, enabled: true, continue_on_error: false, id: "y" },
|
|
98
|
+
],
|
|
99
|
+
};
|
|
100
|
+
const automation: ActionInput[] = [original];
|
|
101
|
+
const taken = collectActionIds(automation);
|
|
102
|
+
const clone = duplicateAction(original, taken);
|
|
103
|
+
const ids = [...collectActionIds([clone])];
|
|
104
|
+
// All fresh ids are distinct.
|
|
105
|
+
expect(new Set(ids).size).toBe(ids.length);
|
|
106
|
+
});
|
|
107
|
+
});
|