@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.
- package/CHANGELOG.md +664 -0
- package/package.json +38 -0
- package/src/components/AutomationMenuItems.tsx +37 -0
- package/src/editor/ActionEditor.tsx +367 -0
- package/src/editor/ActionListEditor.tsx +203 -0
- package/src/editor/AddActionDialog.tsx +225 -0
- package/src/editor/AutomationDefinitionContext.tsx +37 -0
- package/src/editor/AutomationDefinitionEditor.tsx +99 -0
- package/src/editor/ConditionEditor.tsx +218 -0
- package/src/editor/ConditionsEditor.tsx +89 -0
- package/src/editor/ItemPicker.tsx +147 -0
- package/src/editor/TriggersEditor.tsx +269 -0
- package/src/editor/action-composite-cards.tsx +390 -0
- package/src/editor/action-helpers.ts +365 -0
- package/src/editor/action-leaf-cards.tsx +426 -0
- package/src/editor/editor-validation.test.ts +95 -0
- package/src/editor/editor-validation.tsx +200 -0
- package/src/editor/registry-context.tsx +192 -0
- package/src/editor/template-completion.test.ts +412 -0
- package/src/editor/template-completion.ts +664 -0
- package/src/editor/template-helpers.test.ts +145 -0
- package/src/editor/template-helpers.ts +95 -0
- package/src/editor/trigger-helpers.test.ts +58 -0
- package/src/editor/trigger-helpers.ts +67 -0
- package/src/editor/useConnectionOptionResolvers.ts +80 -0
- package/src/editor/yaml-markers.ts +116 -0
- package/src/index.tsx +95 -0
- package/src/pages/AutomationEditPage.tsx +567 -0
- package/src/pages/AutomationListPage.tsx +304 -0
- package/src/pages/RunDetailPage.tsx +333 -0
- package/src/pages/RunsPage.tsx +233 -0
- package/src/pages/TemplatePlaygroundPage.tsx +224 -0
- package/src/script-context.test.ts +247 -0
- package/src/script-context.ts +218 -0
- package/tsconfig.json +29 -0
package/package.json
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@checkstack/automation-frontend",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"license": "Elastic-2.0",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "src/index.tsx",
|
|
7
|
+
"checkstack": {
|
|
8
|
+
"type": "frontend"
|
|
9
|
+
},
|
|
10
|
+
"scripts": {
|
|
11
|
+
"typecheck": "tsgo -b",
|
|
12
|
+
"lint": "bun run lint:code",
|
|
13
|
+
"lint:code": "eslint . --max-warnings 0"
|
|
14
|
+
},
|
|
15
|
+
"dependencies": {
|
|
16
|
+
"@checkstack/automation-common": "0.1.0",
|
|
17
|
+
"@checkstack/common": "0.11.0",
|
|
18
|
+
"@checkstack/frontend-api": "0.5.2",
|
|
19
|
+
"@checkstack/integration-common": "0.5.0",
|
|
20
|
+
"@checkstack/signal-frontend": "0.1.4",
|
|
21
|
+
"@checkstack/template-engine": "0.1.0",
|
|
22
|
+
"@checkstack/ui": "1.10.0",
|
|
23
|
+
"@dnd-kit/core": "^6.3.1",
|
|
24
|
+
"@dnd-kit/sortable": "^8.0.0",
|
|
25
|
+
"@dnd-kit/utilities": "^3.2.2",
|
|
26
|
+
"date-fns": "^4.1.0",
|
|
27
|
+
"lucide-react": "^0.344.0",
|
|
28
|
+
"react": "^18.2.0",
|
|
29
|
+
"react-router-dom": "^6.22.0",
|
|
30
|
+
"yaml": "^2.6.1"
|
|
31
|
+
},
|
|
32
|
+
"devDependencies": {
|
|
33
|
+
"typescript": "^5.0.0",
|
|
34
|
+
"@types/react": "^18.2.0",
|
|
35
|
+
"@checkstack/tsconfig": "0.0.7",
|
|
36
|
+
"@checkstack/scripts": "0.3.3"
|
|
37
|
+
}
|
|
38
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { Link } from "react-router-dom";
|
|
3
|
+
import { Workflow } from "lucide-react";
|
|
4
|
+
import type { UserMenuItemsContext } from "@checkstack/frontend-api";
|
|
5
|
+
import { DropdownMenuItem } from "@checkstack/ui";
|
|
6
|
+
import { resolveRoute } from "@checkstack/common";
|
|
7
|
+
import {
|
|
8
|
+
automationRoutes,
|
|
9
|
+
automationAccess,
|
|
10
|
+
pluginMetadata,
|
|
11
|
+
} from "@checkstack/automation-common";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* "Automations" entry in the user menu. Only renders for users with
|
|
15
|
+
* `automation.read` access — mirrors `incident-frontend`'s pattern of
|
|
16
|
+
* gating the menu item rather than the underlying route (the route is
|
|
17
|
+
* also access-gated by `createFrontendPlugin`, but hiding the link is
|
|
18
|
+
* the cleaner UX for unauthorised users).
|
|
19
|
+
*/
|
|
20
|
+
export const AutomationMenuItems = ({
|
|
21
|
+
accessRules: userPerms,
|
|
22
|
+
}: UserMenuItemsContext) => {
|
|
23
|
+
const qualifiedId = `${pluginMetadata.pluginId}.${automationAccess.read.id}`;
|
|
24
|
+
const canRead = userPerms.includes("*") || userPerms.includes(qualifiedId);
|
|
25
|
+
|
|
26
|
+
if (!canRead) {
|
|
27
|
+
return <React.Fragment />;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
return (
|
|
31
|
+
<Link to={resolveRoute(automationRoutes.routes.list)}>
|
|
32
|
+
<DropdownMenuItem icon={<Workflow className="w-4 h-4" />}>
|
|
33
|
+
Automations
|
|
34
|
+
</DropdownMenuItem>
|
|
35
|
+
</Link>
|
|
36
|
+
);
|
|
37
|
+
};
|
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import {
|
|
3
|
+
ActionCard,
|
|
4
|
+
Checkbox,
|
|
5
|
+
cn,
|
|
6
|
+
Input,
|
|
7
|
+
Label,
|
|
8
|
+
} from "@checkstack/ui";
|
|
9
|
+
import type {
|
|
10
|
+
ActionInput,
|
|
11
|
+
ActionPath,
|
|
12
|
+
AutomationDefinition,
|
|
13
|
+
ChooseInput,
|
|
14
|
+
ConditionGuardInput,
|
|
15
|
+
DelayInput,
|
|
16
|
+
ParallelInput,
|
|
17
|
+
ProviderAction,
|
|
18
|
+
RepeatInput,
|
|
19
|
+
SequenceInput,
|
|
20
|
+
StopInput,
|
|
21
|
+
VariablesInput,
|
|
22
|
+
WaitForTriggerInput,
|
|
23
|
+
} from "@checkstack/automation-common";
|
|
24
|
+
import {
|
|
25
|
+
ACTION_KIND_META,
|
|
26
|
+
actionDisplayName,
|
|
27
|
+
actionKindOf,
|
|
28
|
+
collectActionIds,
|
|
29
|
+
defaultActionId,
|
|
30
|
+
} from "./action-helpers";
|
|
31
|
+
import { useAutomationRegistry, useVariableScope } from "./registry-context";
|
|
32
|
+
import { useActionIssues } from "./editor-validation";
|
|
33
|
+
import {
|
|
34
|
+
ConditionGuardActionBody,
|
|
35
|
+
DelayActionBody,
|
|
36
|
+
ProviderActionBody,
|
|
37
|
+
StopActionBody,
|
|
38
|
+
VariablesActionBody,
|
|
39
|
+
WaitForTriggerActionBody,
|
|
40
|
+
} from "./action-leaf-cards";
|
|
41
|
+
import {
|
|
42
|
+
ChooseActionBody,
|
|
43
|
+
ParallelActionBody,
|
|
44
|
+
RepeatActionBody,
|
|
45
|
+
SequenceActionBody,
|
|
46
|
+
} from "./action-composite-cards";
|
|
47
|
+
|
|
48
|
+
export interface ActionEditorProps {
|
|
49
|
+
value: ActionInput;
|
|
50
|
+
onChange: (next: ActionInput) => void;
|
|
51
|
+
onDelete: () => void;
|
|
52
|
+
path: ActionPath;
|
|
53
|
+
definition: AutomationDefinition;
|
|
54
|
+
dragHandleProps?: React.HTMLAttributes<HTMLButtonElement>;
|
|
55
|
+
stableId: string;
|
|
56
|
+
disabled?: boolean;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Compact, uppercase "eyebrow" label for the action's secondary metadata
|
|
61
|
+
* fields (type / id / description / failure behaviour). Deliberately
|
|
62
|
+
* smaller and quieter than the `DynamicForm` config field labels so the
|
|
63
|
+
* action's own configuration stays the focal point of the card.
|
|
64
|
+
*/
|
|
65
|
+
const MetaField: React.FC<{
|
|
66
|
+
label: string;
|
|
67
|
+
htmlFor?: string;
|
|
68
|
+
children: React.ReactNode;
|
|
69
|
+
}> = ({ label, htmlFor, children }) => (
|
|
70
|
+
<div className="space-y-1.5">
|
|
71
|
+
<label
|
|
72
|
+
htmlFor={htmlFor}
|
|
73
|
+
className="block text-[11px] font-medium uppercase tracking-wide text-muted-foreground"
|
|
74
|
+
>
|
|
75
|
+
{label}
|
|
76
|
+
</label>
|
|
77
|
+
{children}
|
|
78
|
+
</div>
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Dispatch component — given an `ActionInput`, picks the right card
|
|
83
|
+
* body and wraps it in a shared `ActionCard` from `@checkstack/ui` so
|
|
84
|
+
* every action has the same collapsible header, enable toggle, drag
|
|
85
|
+
* handle, and delete button.
|
|
86
|
+
*
|
|
87
|
+
* Re-deriving the kind on every render is fine: the discriminator is
|
|
88
|
+
* structural and cheap to check.
|
|
89
|
+
*/
|
|
90
|
+
export const ActionEditor: React.FC<ActionEditorProps> = ({
|
|
91
|
+
value,
|
|
92
|
+
onChange,
|
|
93
|
+
onDelete,
|
|
94
|
+
path,
|
|
95
|
+
definition,
|
|
96
|
+
dragHandleProps,
|
|
97
|
+
stableId,
|
|
98
|
+
disabled,
|
|
99
|
+
}) => {
|
|
100
|
+
const { actions } = useAutomationRegistry();
|
|
101
|
+
const kind = actionKindOf(value);
|
|
102
|
+
const meta = ACTION_KIND_META[kind];
|
|
103
|
+
const {
|
|
104
|
+
templateProperties,
|
|
105
|
+
variableNodes,
|
|
106
|
+
templateCompletion,
|
|
107
|
+
expressionCompletion,
|
|
108
|
+
typeDefinitions,
|
|
109
|
+
shellEnvVars,
|
|
110
|
+
} = useVariableScope({
|
|
111
|
+
definition,
|
|
112
|
+
path,
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
const title = actionDisplayName(value, (qualified) =>
|
|
116
|
+
actions.find((a) => a.qualifiedId === qualified)?.displayName,
|
|
117
|
+
);
|
|
118
|
+
|
|
119
|
+
const description =
|
|
120
|
+
value.description ?? (value.id ? `id: ${value.id}` : undefined);
|
|
121
|
+
|
|
122
|
+
const enabledValue = value.enabled !== false;
|
|
123
|
+
const issues = useActionIssues(path);
|
|
124
|
+
|
|
125
|
+
return (
|
|
126
|
+
<ActionCard
|
|
127
|
+
id={stableId}
|
|
128
|
+
title={title}
|
|
129
|
+
description={description}
|
|
130
|
+
category={meta.label}
|
|
131
|
+
icon={meta.icon}
|
|
132
|
+
enabled={enabledValue}
|
|
133
|
+
onEnabledChange={
|
|
134
|
+
disabled
|
|
135
|
+
? undefined
|
|
136
|
+
: (next) => onChange({ ...value, enabled: next })
|
|
137
|
+
}
|
|
138
|
+
onDelete={disabled ? undefined : onDelete}
|
|
139
|
+
dragHandleProps={disabled ? undefined : dragHandleProps}
|
|
140
|
+
errors={issues}
|
|
141
|
+
>
|
|
142
|
+
<div className="space-y-4">
|
|
143
|
+
{/* Action settings — identity and failure behaviour. Grouped in a
|
|
144
|
+
quiet panel and labelled with small uppercase eyebrows so the
|
|
145
|
+
action's own configuration below reads as the primary content of
|
|
146
|
+
the card rather than competing with it. The action's kind is
|
|
147
|
+
fixed at creation; to change it, add a new step and delete this
|
|
148
|
+
one. */}
|
|
149
|
+
<div className="rounded-lg border border-border/60 bg-muted/20 p-3 space-y-3">
|
|
150
|
+
<div className="grid gap-x-4 gap-y-3 sm:grid-cols-2">
|
|
151
|
+
<MetaField label="Id" htmlFor={`${stableId}-id`}>
|
|
152
|
+
<Input
|
|
153
|
+
id={`${stableId}-id`}
|
|
154
|
+
value={value.id ?? ""}
|
|
155
|
+
onChange={(event) =>
|
|
156
|
+
onChange({ ...value, id: event.target.value || undefined })
|
|
157
|
+
}
|
|
158
|
+
onBlur={() => {
|
|
159
|
+
// Never leave the id blank: re-fill a unique, log-friendly
|
|
160
|
+
// default so the action stays referenceable
|
|
161
|
+
// (artifacts.<id>.<name>) and parseable in run logs.
|
|
162
|
+
if (value.id) return;
|
|
163
|
+
const taken = collectActionIds(definition.actions);
|
|
164
|
+
onChange({ ...value, id: defaultActionId(value, taken) });
|
|
165
|
+
}}
|
|
166
|
+
placeholder="Generated on blur"
|
|
167
|
+
disabled={disabled}
|
|
168
|
+
className="h-8 text-xs"
|
|
169
|
+
/>
|
|
170
|
+
</MetaField>
|
|
171
|
+
|
|
172
|
+
<MetaField label="Description" htmlFor={`${stableId}-desc`}>
|
|
173
|
+
<Input
|
|
174
|
+
id={`${stableId}-desc`}
|
|
175
|
+
value={value.description ?? ""}
|
|
176
|
+
onChange={(event) =>
|
|
177
|
+
onChange({
|
|
178
|
+
...value,
|
|
179
|
+
description: event.target.value || undefined,
|
|
180
|
+
})
|
|
181
|
+
}
|
|
182
|
+
placeholder="Optional note"
|
|
183
|
+
disabled={disabled}
|
|
184
|
+
className="h-8 text-xs"
|
|
185
|
+
/>
|
|
186
|
+
</MetaField>
|
|
187
|
+
</div>
|
|
188
|
+
|
|
189
|
+
<MetaField label="On failure">
|
|
190
|
+
<div
|
|
191
|
+
className={cn(
|
|
192
|
+
"flex h-8 items-center gap-2 select-none",
|
|
193
|
+
disabled ? "cursor-not-allowed" : "cursor-pointer",
|
|
194
|
+
)}
|
|
195
|
+
onClick={() =>
|
|
196
|
+
!disabled &&
|
|
197
|
+
onChange({
|
|
198
|
+
...value,
|
|
199
|
+
continue_on_error: value.continue_on_error !== true,
|
|
200
|
+
})
|
|
201
|
+
}
|
|
202
|
+
>
|
|
203
|
+
<Checkbox
|
|
204
|
+
checked={value.continue_on_error === true}
|
|
205
|
+
disabled={disabled}
|
|
206
|
+
/>
|
|
207
|
+
<Label className="text-xs font-normal text-muted-foreground cursor-pointer">
|
|
208
|
+
Continue on error
|
|
209
|
+
</Label>
|
|
210
|
+
</div>
|
|
211
|
+
</MetaField>
|
|
212
|
+
</div>
|
|
213
|
+
|
|
214
|
+
<ActionBody
|
|
215
|
+
kind={kind}
|
|
216
|
+
value={value}
|
|
217
|
+
onChange={onChange}
|
|
218
|
+
path={path}
|
|
219
|
+
templateProperties={templateProperties}
|
|
220
|
+
variableNodes={variableNodes}
|
|
221
|
+
templateCompletion={templateCompletion}
|
|
222
|
+
expressionCompletion={expressionCompletion}
|
|
223
|
+
typeDefinitions={typeDefinitions}
|
|
224
|
+
shellEnvVars={shellEnvVars}
|
|
225
|
+
disabled={disabled}
|
|
226
|
+
/>
|
|
227
|
+
</div>
|
|
228
|
+
</ActionCard>
|
|
229
|
+
);
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
const ActionBody: React.FC<{
|
|
233
|
+
kind: ReturnType<typeof actionKindOf>;
|
|
234
|
+
value: ActionInput;
|
|
235
|
+
onChange: (next: ActionInput) => void;
|
|
236
|
+
path: ActionPath;
|
|
237
|
+
templateProperties: Array<{ path: string; type: string; description?: string }>;
|
|
238
|
+
variableNodes: Parameters<typeof ConditionGuardActionBody>[0]["variableNodes"];
|
|
239
|
+
templateCompletion: Parameters<typeof DelayActionBody>[0]["completionProvider"];
|
|
240
|
+
expressionCompletion: Parameters<
|
|
241
|
+
typeof ConditionGuardActionBody
|
|
242
|
+
>[0]["completionProvider"];
|
|
243
|
+
typeDefinitions: string;
|
|
244
|
+
shellEnvVars: Parameters<typeof ProviderActionBody>[0]["shellEnvVars"];
|
|
245
|
+
disabled?: boolean;
|
|
246
|
+
}> = ({
|
|
247
|
+
kind,
|
|
248
|
+
value,
|
|
249
|
+
onChange,
|
|
250
|
+
path,
|
|
251
|
+
templateProperties,
|
|
252
|
+
variableNodes,
|
|
253
|
+
templateCompletion,
|
|
254
|
+
expressionCompletion,
|
|
255
|
+
typeDefinitions,
|
|
256
|
+
shellEnvVars,
|
|
257
|
+
disabled,
|
|
258
|
+
}) => {
|
|
259
|
+
switch (kind) {
|
|
260
|
+
case "action": {
|
|
261
|
+
return (
|
|
262
|
+
<ProviderActionBody
|
|
263
|
+
value={value as ProviderAction}
|
|
264
|
+
onChange={(next) => onChange(next)}
|
|
265
|
+
templateProperties={templateProperties}
|
|
266
|
+
completionProvider={templateCompletion}
|
|
267
|
+
typeDefinitions={typeDefinitions}
|
|
268
|
+
shellEnvVars={shellEnvVars}
|
|
269
|
+
disabled={disabled}
|
|
270
|
+
/>
|
|
271
|
+
);
|
|
272
|
+
}
|
|
273
|
+
case "choose": {
|
|
274
|
+
return (
|
|
275
|
+
<ChooseActionBody
|
|
276
|
+
value={value as ChooseInput}
|
|
277
|
+
onChange={(next) => onChange(next)}
|
|
278
|
+
path={path}
|
|
279
|
+
variableNodes={variableNodes}
|
|
280
|
+
expressionCompletion={expressionCompletion}
|
|
281
|
+
disabled={disabled}
|
|
282
|
+
/>
|
|
283
|
+
);
|
|
284
|
+
}
|
|
285
|
+
case "parallel": {
|
|
286
|
+
return (
|
|
287
|
+
<ParallelActionBody
|
|
288
|
+
value={value as ParallelInput}
|
|
289
|
+
onChange={(next) => onChange(next)}
|
|
290
|
+
path={path}
|
|
291
|
+
disabled={disabled}
|
|
292
|
+
/>
|
|
293
|
+
);
|
|
294
|
+
}
|
|
295
|
+
case "repeat": {
|
|
296
|
+
return (
|
|
297
|
+
<RepeatActionBody
|
|
298
|
+
value={value as RepeatInput}
|
|
299
|
+
onChange={(next) => onChange(next)}
|
|
300
|
+
path={path}
|
|
301
|
+
completionProvider={templateCompletion}
|
|
302
|
+
disabled={disabled}
|
|
303
|
+
/>
|
|
304
|
+
);
|
|
305
|
+
}
|
|
306
|
+
case "variables": {
|
|
307
|
+
return (
|
|
308
|
+
<VariablesActionBody
|
|
309
|
+
value={value as VariablesInput}
|
|
310
|
+
onChange={(next) => onChange(next)}
|
|
311
|
+
templateProperties={templateProperties}
|
|
312
|
+
disabled={disabled}
|
|
313
|
+
/>
|
|
314
|
+
);
|
|
315
|
+
}
|
|
316
|
+
case "condition": {
|
|
317
|
+
return (
|
|
318
|
+
<ConditionGuardActionBody
|
|
319
|
+
value={value as ConditionGuardInput}
|
|
320
|
+
onChange={(next) => onChange(next)}
|
|
321
|
+
variableNodes={variableNodes}
|
|
322
|
+
completionProvider={expressionCompletion}
|
|
323
|
+
disabled={disabled}
|
|
324
|
+
/>
|
|
325
|
+
);
|
|
326
|
+
}
|
|
327
|
+
case "stop": {
|
|
328
|
+
return (
|
|
329
|
+
<StopActionBody
|
|
330
|
+
value={value as StopInput}
|
|
331
|
+
onChange={(next) => onChange(next)}
|
|
332
|
+
disabled={disabled}
|
|
333
|
+
/>
|
|
334
|
+
);
|
|
335
|
+
}
|
|
336
|
+
case "wait_for_trigger": {
|
|
337
|
+
return (
|
|
338
|
+
<WaitForTriggerActionBody
|
|
339
|
+
value={value as WaitForTriggerInput}
|
|
340
|
+
onChange={(next) => onChange(next)}
|
|
341
|
+
completionProvider={templateCompletion}
|
|
342
|
+
disabled={disabled}
|
|
343
|
+
/>
|
|
344
|
+
);
|
|
345
|
+
}
|
|
346
|
+
case "sequence": {
|
|
347
|
+
return (
|
|
348
|
+
<SequenceActionBody
|
|
349
|
+
value={value as SequenceInput}
|
|
350
|
+
onChange={(next) => onChange(next)}
|
|
351
|
+
path={path}
|
|
352
|
+
disabled={disabled}
|
|
353
|
+
/>
|
|
354
|
+
);
|
|
355
|
+
}
|
|
356
|
+
case "delay": {
|
|
357
|
+
return (
|
|
358
|
+
<DelayActionBody
|
|
359
|
+
value={value as DelayInput}
|
|
360
|
+
onChange={(next) => onChange(next)}
|
|
361
|
+
completionProvider={templateCompletion}
|
|
362
|
+
disabled={disabled}
|
|
363
|
+
/>
|
|
364
|
+
);
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
};
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import {
|
|
3
|
+
DndContext,
|
|
4
|
+
type DragEndEvent,
|
|
5
|
+
PointerSensor,
|
|
6
|
+
closestCenter,
|
|
7
|
+
useSensor,
|
|
8
|
+
useSensors,
|
|
9
|
+
} from "@dnd-kit/core";
|
|
10
|
+
import {
|
|
11
|
+
SortableContext,
|
|
12
|
+
arrayMove,
|
|
13
|
+
useSortable,
|
|
14
|
+
verticalListSortingStrategy,
|
|
15
|
+
} from "@dnd-kit/sortable";
|
|
16
|
+
import { CSS } from "@dnd-kit/utilities";
|
|
17
|
+
import type {
|
|
18
|
+
ActionInput,
|
|
19
|
+
ActionPath,
|
|
20
|
+
ActionPathStep,
|
|
21
|
+
} from "@checkstack/automation-common";
|
|
22
|
+
import {
|
|
23
|
+
assignDefaultIds,
|
|
24
|
+
collectActionIds,
|
|
25
|
+
makeEmptyAction,
|
|
26
|
+
makeProviderAction,
|
|
27
|
+
} from "./action-helpers";
|
|
28
|
+
import { ActionEditor } from "./ActionEditor";
|
|
29
|
+
import { AddActionDialog } from "./AddActionDialog";
|
|
30
|
+
import { useAutomationDefinitionContext } from "./AutomationDefinitionContext";
|
|
31
|
+
|
|
32
|
+
type Slot = ActionPathStep["slot"];
|
|
33
|
+
|
|
34
|
+
export interface ActionListEditorProps {
|
|
35
|
+
value: ActionInput[];
|
|
36
|
+
onChange: (next: ActionInput[]) => void;
|
|
37
|
+
/** Path of the parent action — empty array means we're at the root. */
|
|
38
|
+
parentPath: ActionPath;
|
|
39
|
+
/**
|
|
40
|
+
* Describes which child slot of the parent contains this list. Drives
|
|
41
|
+
* `ActionPathStep`s for nested resolution. Use `{ slot: "root" }` at the
|
|
42
|
+
* top of the editor; composite cards pass their own (`"choose-when"` with
|
|
43
|
+
* `whenIndex`, `"parallel"`, `"repeat"`, `"sequence"`, `"choose-else"`).
|
|
44
|
+
*/
|
|
45
|
+
slotForChildren: { slot: Slot; whenIndex?: number };
|
|
46
|
+
disabled?: boolean;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
let idCounter = 0;
|
|
50
|
+
const nextId = (): string => {
|
|
51
|
+
idCounter += 1;
|
|
52
|
+
return `act-${idCounter}`;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Sortable list of actions. The hard parts:
|
|
57
|
+
*
|
|
58
|
+
* 1. **Drag-to-reorder** — we pair every value-slot with a stable
|
|
59
|
+
* `string` id (kept in a parallel array). Reorders shuffle both
|
|
60
|
+
* arrays together; edits don't touch the id array, so each card's
|
|
61
|
+
* expanded-state survives parent re-renders.
|
|
62
|
+
*
|
|
63
|
+
* 2. **Path computation** — each child's `ActionPath` is the parent
|
|
64
|
+
* path plus a final segment describing the child's position. The
|
|
65
|
+
* segment's `slot` comes from `slotForChildren`; the `index` is
|
|
66
|
+
* the child's position in the array.
|
|
67
|
+
*
|
|
68
|
+
* 3. **Add menu** — `AddActionDialog` lets the operator decide the
|
|
69
|
+
* step's type up front: a concrete provider action (preset via
|
|
70
|
+
* `makeProviderAction`) or a structural building block. Composite
|
|
71
|
+
* kinds start with an empty child list, which the operator fills via
|
|
72
|
+
* the nested add-step picker.
|
|
73
|
+
*/
|
|
74
|
+
export const ActionListEditor: React.FC<ActionListEditorProps> = ({
|
|
75
|
+
value,
|
|
76
|
+
onChange,
|
|
77
|
+
parentPath,
|
|
78
|
+
slotForChildren,
|
|
79
|
+
disabled,
|
|
80
|
+
}) => {
|
|
81
|
+
const { definition } = useAutomationDefinitionContext();
|
|
82
|
+
const sensors = useSensors(
|
|
83
|
+
useSensor(PointerSensor, { activationConstraint: { distance: 6 } }),
|
|
84
|
+
);
|
|
85
|
+
|
|
86
|
+
// Parallel id array; kept in sync with `value`'s length so reorders +
|
|
87
|
+
// add + delete are stable, while in-place edits don't churn keys.
|
|
88
|
+
const [ids, setIds] = React.useState<string[]>(() => value.map(() => nextId()));
|
|
89
|
+
React.useEffect(() => {
|
|
90
|
+
setIds((current) => {
|
|
91
|
+
if (current.length === value.length) return current;
|
|
92
|
+
if (current.length < value.length) {
|
|
93
|
+
return [
|
|
94
|
+
...current,
|
|
95
|
+
...Array.from({ length: value.length - current.length }, nextId),
|
|
96
|
+
];
|
|
97
|
+
}
|
|
98
|
+
return current.slice(0, value.length);
|
|
99
|
+
});
|
|
100
|
+
}, [value.length]);
|
|
101
|
+
|
|
102
|
+
const handleDragEnd = (event: DragEndEvent) => {
|
|
103
|
+
if (!event.over || event.active.id === event.over.id) return;
|
|
104
|
+
const oldIndex = ids.indexOf(String(event.active.id));
|
|
105
|
+
const newIndex = ids.indexOf(String(event.over.id));
|
|
106
|
+
if (oldIndex === -1 || newIndex === -1) return;
|
|
107
|
+
setIds(arrayMove(ids, oldIndex, newIndex));
|
|
108
|
+
onChange(arrayMove(value, oldIndex, newIndex));
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
const childPathAt = (index: number): ActionPath => [
|
|
112
|
+
...parentPath,
|
|
113
|
+
{
|
|
114
|
+
slot: slotForChildren.slot,
|
|
115
|
+
whenIndex: slotForChildren.whenIndex,
|
|
116
|
+
index,
|
|
117
|
+
},
|
|
118
|
+
];
|
|
119
|
+
|
|
120
|
+
const appendStep = (action: ActionInput): void => {
|
|
121
|
+
// Auto-assign log-friendly default ids (deduped against every id already
|
|
122
|
+
// used anywhere in the automation), covering the new step and any
|
|
123
|
+
// children composite kinds prime themselves with. The operator can
|
|
124
|
+
// rename them in the card.
|
|
125
|
+
const taken = collectActionIds(definition.actions);
|
|
126
|
+
const [fresh] = assignDefaultIds([action], taken);
|
|
127
|
+
onChange([...value, fresh!]);
|
|
128
|
+
setIds((current) => [...current, nextId()]);
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
return (
|
|
132
|
+
<div className="space-y-2">
|
|
133
|
+
<DndContext
|
|
134
|
+
sensors={sensors}
|
|
135
|
+
collisionDetection={closestCenter}
|
|
136
|
+
onDragEnd={handleDragEnd}
|
|
137
|
+
>
|
|
138
|
+
<SortableContext items={ids} strategy={verticalListSortingStrategy}>
|
|
139
|
+
{value.map((action, index) => (
|
|
140
|
+
<SortableActionItem
|
|
141
|
+
key={ids[index]}
|
|
142
|
+
id={ids[index]!}
|
|
143
|
+
value={action}
|
|
144
|
+
onChange={(next) => {
|
|
145
|
+
const list = [...value];
|
|
146
|
+
list[index] = next;
|
|
147
|
+
onChange(list);
|
|
148
|
+
}}
|
|
149
|
+
onDelete={() => {
|
|
150
|
+
onChange(value.filter((_, i) => i !== index));
|
|
151
|
+
setIds((current) => current.filter((_, i) => i !== index));
|
|
152
|
+
}}
|
|
153
|
+
path={childPathAt(index)}
|
|
154
|
+
definition={definition}
|
|
155
|
+
disabled={disabled}
|
|
156
|
+
/>
|
|
157
|
+
))}
|
|
158
|
+
</SortableContext>
|
|
159
|
+
</DndContext>
|
|
160
|
+
{value.length === 0 && (
|
|
161
|
+
<p className="text-xs italic text-muted-foreground">No steps yet.</p>
|
|
162
|
+
)}
|
|
163
|
+
<AddActionDialog
|
|
164
|
+
disabled={disabled}
|
|
165
|
+
onAddKind={(kind) => appendStep(makeEmptyAction(kind))}
|
|
166
|
+
onAddAction={(qualifiedId) => appendStep(makeProviderAction(qualifiedId))}
|
|
167
|
+
/>
|
|
168
|
+
</div>
|
|
169
|
+
);
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
const SortableActionItem: React.FC<{
|
|
173
|
+
id: string;
|
|
174
|
+
value: ActionInput;
|
|
175
|
+
onChange: (next: ActionInput) => void;
|
|
176
|
+
onDelete: () => void;
|
|
177
|
+
path: ActionPath;
|
|
178
|
+
definition: Parameters<typeof ActionEditor>[0]["definition"];
|
|
179
|
+
disabled?: boolean;
|
|
180
|
+
}> = ({ id, value, onChange, onDelete, path, definition, disabled }) => {
|
|
181
|
+
const sortable = useSortable({ id });
|
|
182
|
+
const style: React.CSSProperties = {
|
|
183
|
+
transform: CSS.Transform.toString(sortable.transform),
|
|
184
|
+
transition: sortable.transition,
|
|
185
|
+
};
|
|
186
|
+
return (
|
|
187
|
+
<div ref={sortable.setNodeRef} style={style}>
|
|
188
|
+
<ActionEditor
|
|
189
|
+
value={value}
|
|
190
|
+
onChange={onChange}
|
|
191
|
+
onDelete={onDelete}
|
|
192
|
+
path={path}
|
|
193
|
+
definition={definition}
|
|
194
|
+
stableId={id}
|
|
195
|
+
dragHandleProps={{
|
|
196
|
+
...sortable.attributes,
|
|
197
|
+
...sortable.listeners,
|
|
198
|
+
}}
|
|
199
|
+
disabled={disabled}
|
|
200
|
+
/>
|
|
201
|
+
</div>
|
|
202
|
+
);
|
|
203
|
+
};
|