@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
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { Plus, Search, Zap } from "lucide-react";
|
|
3
|
+
import {
|
|
4
|
+
Button,
|
|
5
|
+
Dialog,
|
|
6
|
+
DialogContent,
|
|
7
|
+
DialogHeader,
|
|
8
|
+
DialogTitle,
|
|
9
|
+
DynamicIcon,
|
|
10
|
+
Input,
|
|
11
|
+
TabPanel,
|
|
12
|
+
Tabs,
|
|
13
|
+
} from "@checkstack/ui";
|
|
14
|
+
import { ACTION_KIND_META, type ActionKind } from "./action-helpers";
|
|
15
|
+
import { useAutomationRegistry } from "./registry-context";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Building blocks shown in the "Blocks" tab — every action kind except the
|
|
19
|
+
* generic provider `action`. Concrete provider actions are chosen directly
|
|
20
|
+
* from the "Actions" tab (which presets the step's `action`), so the kind is
|
|
21
|
+
* always decided up front rather than swapped on an existing step.
|
|
22
|
+
*/
|
|
23
|
+
const BLOCK_KINDS: ActionKind[] = [
|
|
24
|
+
"choose",
|
|
25
|
+
"parallel",
|
|
26
|
+
"repeat",
|
|
27
|
+
"sequence",
|
|
28
|
+
"condition",
|
|
29
|
+
"delay",
|
|
30
|
+
"stop",
|
|
31
|
+
"variables",
|
|
32
|
+
"wait_for_trigger",
|
|
33
|
+
];
|
|
34
|
+
|
|
35
|
+
const TAB_ITEMS = [
|
|
36
|
+
{ id: "actions", label: "Actions" },
|
|
37
|
+
{ id: "blocks", label: "Blocks" },
|
|
38
|
+
];
|
|
39
|
+
|
|
40
|
+
/** Shared row used by both tabs — icon, name, description, and a `+` affordance. */
|
|
41
|
+
const PickerRow: React.FC<{
|
|
42
|
+
icon: React.ReactNode;
|
|
43
|
+
title: string;
|
|
44
|
+
description?: string;
|
|
45
|
+
onClick: () => void;
|
|
46
|
+
}> = ({ icon, title, description, onClick }) => (
|
|
47
|
+
<button
|
|
48
|
+
type="button"
|
|
49
|
+
onClick={onClick}
|
|
50
|
+
className="flex w-full items-start gap-3 rounded-md border border-border/60 bg-card px-3 py-2.5 text-left transition-colors hover:border-border hover:bg-accent/50"
|
|
51
|
+
>
|
|
52
|
+
<span className="mt-0.5 shrink-0 text-muted-foreground">{icon}</span>
|
|
53
|
+
<div className="min-w-0 flex-1">
|
|
54
|
+
<div className="text-sm font-medium">{title}</div>
|
|
55
|
+
{description && (
|
|
56
|
+
<div className="text-xs text-muted-foreground">{description}</div>
|
|
57
|
+
)}
|
|
58
|
+
</div>
|
|
59
|
+
<Plus className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
|
|
60
|
+
</button>
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
export interface AddActionDialogProps {
|
|
64
|
+
/** Insert a building-block step of the given kind. */
|
|
65
|
+
onAddKind: (kind: ActionKind) => void;
|
|
66
|
+
/** Insert a provider-action step with its `action` preset to this id. */
|
|
67
|
+
onAddAction: (qualifiedId: string) => void;
|
|
68
|
+
disabled?: boolean;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Home-Assistant-style step picker. The operator decides the step's type up
|
|
73
|
+
* front: pick a concrete provider action (grouped by category) from the
|
|
74
|
+
* "Actions" tab, or a structural building block from the "Blocks" tab. A
|
|
75
|
+
* single search filters whichever tab is active.
|
|
76
|
+
*/
|
|
77
|
+
export const AddActionDialog: React.FC<AddActionDialogProps> = ({
|
|
78
|
+
onAddKind,
|
|
79
|
+
onAddAction,
|
|
80
|
+
disabled,
|
|
81
|
+
}) => {
|
|
82
|
+
const { actions } = useAutomationRegistry();
|
|
83
|
+
const [open, setOpen] = React.useState(false);
|
|
84
|
+
const [tab, setTab] = React.useState("actions");
|
|
85
|
+
const [query, setQuery] = React.useState("");
|
|
86
|
+
|
|
87
|
+
// Reset transient state whenever the dialog closes so it reopens clean.
|
|
88
|
+
React.useEffect(() => {
|
|
89
|
+
if (!open) {
|
|
90
|
+
setQuery("");
|
|
91
|
+
setTab("actions");
|
|
92
|
+
}
|
|
93
|
+
}, [open]);
|
|
94
|
+
|
|
95
|
+
const q = query.trim().toLowerCase();
|
|
96
|
+
|
|
97
|
+
const filteredActions = React.useMemo(() => {
|
|
98
|
+
if (!q) return actions;
|
|
99
|
+
return actions.filter(
|
|
100
|
+
(action) =>
|
|
101
|
+
action.displayName.toLowerCase().includes(q) ||
|
|
102
|
+
action.qualifiedId.toLowerCase().includes(q) ||
|
|
103
|
+
action.description?.toLowerCase().includes(q) ||
|
|
104
|
+
action.category.toLowerCase().includes(q),
|
|
105
|
+
);
|
|
106
|
+
}, [actions, q]);
|
|
107
|
+
|
|
108
|
+
const groupedActions = React.useMemo(() => {
|
|
109
|
+
const groups = new Map<string, typeof actions>();
|
|
110
|
+
for (const action of filteredActions) {
|
|
111
|
+
const list = groups.get(action.category) ?? [];
|
|
112
|
+
list.push(action);
|
|
113
|
+
groups.set(action.category, list);
|
|
114
|
+
}
|
|
115
|
+
return [...groups.entries()].toSorted(([a], [b]) => a.localeCompare(b));
|
|
116
|
+
}, [filteredActions]);
|
|
117
|
+
|
|
118
|
+
const filteredBlocks = React.useMemo(() => {
|
|
119
|
+
if (!q) return BLOCK_KINDS;
|
|
120
|
+
return BLOCK_KINDS.filter((kind) => {
|
|
121
|
+
const meta = ACTION_KIND_META[kind];
|
|
122
|
+
return (
|
|
123
|
+
meta.label.toLowerCase().includes(q) ||
|
|
124
|
+
meta.description.toLowerCase().includes(q)
|
|
125
|
+
);
|
|
126
|
+
});
|
|
127
|
+
}, [q]);
|
|
128
|
+
|
|
129
|
+
return (
|
|
130
|
+
<>
|
|
131
|
+
<Button
|
|
132
|
+
type="button"
|
|
133
|
+
variant="outline"
|
|
134
|
+
size="sm"
|
|
135
|
+
disabled={disabled}
|
|
136
|
+
className="h-7 text-xs"
|
|
137
|
+
onClick={() => setOpen(true)}
|
|
138
|
+
>
|
|
139
|
+
<Plus className="mr-1 h-3 w-3" />
|
|
140
|
+
Add step
|
|
141
|
+
</Button>
|
|
142
|
+
<Dialog open={open} onOpenChange={setOpen}>
|
|
143
|
+
<DialogContent size="lg">
|
|
144
|
+
<DialogHeader>
|
|
145
|
+
<DialogTitle>Add action</DialogTitle>
|
|
146
|
+
</DialogHeader>
|
|
147
|
+
|
|
148
|
+
<div className="space-y-3">
|
|
149
|
+
<div className="relative">
|
|
150
|
+
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
|
151
|
+
<Input
|
|
152
|
+
autoFocus
|
|
153
|
+
value={query}
|
|
154
|
+
onChange={(event) => setQuery(event.target.value)}
|
|
155
|
+
placeholder="Search actions and blocks…"
|
|
156
|
+
className="pl-9"
|
|
157
|
+
/>
|
|
158
|
+
</div>
|
|
159
|
+
|
|
160
|
+
<Tabs items={TAB_ITEMS} activeTab={tab} onTabChange={setTab} />
|
|
161
|
+
|
|
162
|
+
<div className="max-h-[50vh] overflow-y-auto pr-1">
|
|
163
|
+
<TabPanel id="actions" activeTab={tab}>
|
|
164
|
+
{groupedActions.length === 0 ? (
|
|
165
|
+
<p className="px-1 py-6 text-center text-xs italic text-muted-foreground">
|
|
166
|
+
No matching actions. Looking for choose / repeat / delay?
|
|
167
|
+
Check the Blocks tab.
|
|
168
|
+
</p>
|
|
169
|
+
) : (
|
|
170
|
+
<div className="space-y-3">
|
|
171
|
+
{groupedActions.map(([category, list]) => (
|
|
172
|
+
<div key={category} className="space-y-1">
|
|
173
|
+
<p className="px-1 text-[10px] font-medium uppercase tracking-wide text-muted-foreground">
|
|
174
|
+
{category}
|
|
175
|
+
</p>
|
|
176
|
+
{list.map((action) => (
|
|
177
|
+
<PickerRow
|
|
178
|
+
key={action.qualifiedId}
|
|
179
|
+
icon={<Zap className="h-4 w-4" />}
|
|
180
|
+
title={action.displayName}
|
|
181
|
+
description={action.description}
|
|
182
|
+
onClick={() => {
|
|
183
|
+
onAddAction(action.qualifiedId);
|
|
184
|
+
setOpen(false);
|
|
185
|
+
}}
|
|
186
|
+
/>
|
|
187
|
+
))}
|
|
188
|
+
</div>
|
|
189
|
+
))}
|
|
190
|
+
</div>
|
|
191
|
+
)}
|
|
192
|
+
</TabPanel>
|
|
193
|
+
|
|
194
|
+
<TabPanel id="blocks" activeTab={tab}>
|
|
195
|
+
{filteredBlocks.length === 0 ? (
|
|
196
|
+
<p className="px-1 py-6 text-center text-xs italic text-muted-foreground">
|
|
197
|
+
No matching blocks.
|
|
198
|
+
</p>
|
|
199
|
+
) : (
|
|
200
|
+
<div className="space-y-1">
|
|
201
|
+
{filteredBlocks.map((kind) => {
|
|
202
|
+
const meta = ACTION_KIND_META[kind];
|
|
203
|
+
return (
|
|
204
|
+
<PickerRow
|
|
205
|
+
key={kind}
|
|
206
|
+
icon={<DynamicIcon name={meta.icon} className="h-4 w-4" />}
|
|
207
|
+
title={meta.label}
|
|
208
|
+
description={meta.description}
|
|
209
|
+
onClick={() => {
|
|
210
|
+
onAddKind(kind);
|
|
211
|
+
setOpen(false);
|
|
212
|
+
}}
|
|
213
|
+
/>
|
|
214
|
+
);
|
|
215
|
+
})}
|
|
216
|
+
</div>
|
|
217
|
+
)}
|
|
218
|
+
</TabPanel>
|
|
219
|
+
</div>
|
|
220
|
+
</div>
|
|
221
|
+
</DialogContent>
|
|
222
|
+
</Dialog>
|
|
223
|
+
</>
|
|
224
|
+
);
|
|
225
|
+
};
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import type { AutomationDefinition } from "@checkstack/automation-common";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Threads the live `AutomationDefinition` through the editor tree so
|
|
6
|
+
* recursively-rendered cards can resolve their variable scope against
|
|
7
|
+
* the latest state without prop-drilling. Updated by the top-level
|
|
8
|
+
* `AutomationDefinitionEditor` on every edit.
|
|
9
|
+
*/
|
|
10
|
+
interface AutomationDefinitionContextValue {
|
|
11
|
+
definition: AutomationDefinition;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const AutomationDefinitionContext =
|
|
15
|
+
React.createContext<AutomationDefinitionContextValue | null>(null);
|
|
16
|
+
|
|
17
|
+
export const AutomationDefinitionProvider: React.FC<{
|
|
18
|
+
definition: AutomationDefinition;
|
|
19
|
+
children: React.ReactNode;
|
|
20
|
+
}> = ({ definition, children }) => {
|
|
21
|
+
const value = React.useMemo(() => ({ definition }), [definition]);
|
|
22
|
+
return (
|
|
23
|
+
<AutomationDefinitionContext.Provider value={value}>
|
|
24
|
+
{children}
|
|
25
|
+
</AutomationDefinitionContext.Provider>
|
|
26
|
+
);
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export function useAutomationDefinitionContext(): AutomationDefinitionContextValue {
|
|
30
|
+
const ctx = React.useContext(AutomationDefinitionContext);
|
|
31
|
+
if (!ctx) {
|
|
32
|
+
throw new Error(
|
|
33
|
+
"useAutomationDefinitionContext must be used inside <AutomationDefinitionProvider>",
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
return ctx;
|
|
37
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { Card, CardContent, CardHeader, CardTitle } from "@checkstack/ui";
|
|
3
|
+
import type { AutomationDefinition } from "@checkstack/automation-common";
|
|
4
|
+
import {
|
|
5
|
+
AutomationDefinitionProvider,
|
|
6
|
+
} from "./AutomationDefinitionContext";
|
|
7
|
+
import {
|
|
8
|
+
AutomationRegistryProvider,
|
|
9
|
+
useAutomationRegistry,
|
|
10
|
+
useVariableScope,
|
|
11
|
+
} from "./registry-context";
|
|
12
|
+
import { TriggersEditor } from "./TriggersEditor";
|
|
13
|
+
import { ConditionsEditor } from "./ConditionsEditor";
|
|
14
|
+
import { ActionListEditor } from "./ActionListEditor";
|
|
15
|
+
|
|
16
|
+
export interface AutomationDefinitionEditorProps {
|
|
17
|
+
value: AutomationDefinition;
|
|
18
|
+
onChange: (next: AutomationDefinition) => void;
|
|
19
|
+
disabled?: boolean;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Top-level visual editor for an `AutomationDefinition`. Composes the
|
|
24
|
+
* three sections (triggers, pre-run conditions, actions) and threads
|
|
25
|
+
* the live definition through the registry + definition contexts so
|
|
26
|
+
* every nested template field can resolve its scope independently.
|
|
27
|
+
*
|
|
28
|
+
* Pure composition — no internal state. The parent (`AutomationEditPage`)
|
|
29
|
+
* owns the definition and decides when to save.
|
|
30
|
+
*/
|
|
31
|
+
export const AutomationDefinitionEditor: React.FC<
|
|
32
|
+
AutomationDefinitionEditorProps
|
|
33
|
+
> = (props) => (
|
|
34
|
+
<AutomationRegistryProvider>
|
|
35
|
+
<AutomationDefinitionProvider definition={props.value}>
|
|
36
|
+
<EditorBody {...props} />
|
|
37
|
+
</AutomationDefinitionProvider>
|
|
38
|
+
</AutomationRegistryProvider>
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
const EditorBody: React.FC<AutomationDefinitionEditorProps> = ({
|
|
42
|
+
value,
|
|
43
|
+
onChange,
|
|
44
|
+
disabled,
|
|
45
|
+
}) => {
|
|
46
|
+
const { loading } = useAutomationRegistry();
|
|
47
|
+
|
|
48
|
+
// Scope at the root path = `trigger.*` only. The conditions editor
|
|
49
|
+
// uses `variableNodes` for the explicit "fx" tree and
|
|
50
|
+
// `expressionCompletion` for the staged inline autocomplete (bare
|
|
51
|
+
// expression mode — pre-run conditions aren't `{{ }}`-wrapped).
|
|
52
|
+
const { variableNodes, expressionCompletion } = useVariableScope({
|
|
53
|
+
definition: value,
|
|
54
|
+
path: [{ slot: "root", index: 0 }],
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
return (
|
|
58
|
+
<div className="space-y-4">
|
|
59
|
+
{loading && (
|
|
60
|
+
<Card className="border-dashed">
|
|
61
|
+
<CardContent className="p-3">
|
|
62
|
+
<p className="text-xs italic text-muted-foreground">
|
|
63
|
+
Loading registry…
|
|
64
|
+
</p>
|
|
65
|
+
</CardContent>
|
|
66
|
+
</Card>
|
|
67
|
+
)}
|
|
68
|
+
|
|
69
|
+
<TriggersEditor
|
|
70
|
+
value={value.triggers}
|
|
71
|
+
onChange={(triggers) => onChange({ ...value, triggers })}
|
|
72
|
+
disabled={disabled}
|
|
73
|
+
/>
|
|
74
|
+
|
|
75
|
+
<ConditionsEditor
|
|
76
|
+
value={value.conditions}
|
|
77
|
+
onChange={(conditions) => onChange({ ...value, conditions })}
|
|
78
|
+
variableNodes={variableNodes}
|
|
79
|
+
completionProvider={expressionCompletion}
|
|
80
|
+
disabled={disabled}
|
|
81
|
+
/>
|
|
82
|
+
|
|
83
|
+
<Card>
|
|
84
|
+
<CardHeader className="border-b">
|
|
85
|
+
<CardTitle className="text-base">Actions</CardTitle>
|
|
86
|
+
</CardHeader>
|
|
87
|
+
<CardContent className="space-y-2 p-3">
|
|
88
|
+
<ActionListEditor
|
|
89
|
+
value={value.actions}
|
|
90
|
+
onChange={(actions) => onChange({ ...value, actions })}
|
|
91
|
+
parentPath={[]}
|
|
92
|
+
slotForChildren={{ slot: "root" }}
|
|
93
|
+
disabled={disabled}
|
|
94
|
+
/>
|
|
95
|
+
</CardContent>
|
|
96
|
+
</Card>
|
|
97
|
+
</div>
|
|
98
|
+
);
|
|
99
|
+
};
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { Plus, Trash2 } from "lucide-react";
|
|
3
|
+
import {
|
|
4
|
+
Button,
|
|
5
|
+
Card,
|
|
6
|
+
CardContent,
|
|
7
|
+
Select,
|
|
8
|
+
SelectContent,
|
|
9
|
+
SelectItem,
|
|
10
|
+
SelectTrigger,
|
|
11
|
+
SelectValue,
|
|
12
|
+
TemplateValueInput,
|
|
13
|
+
VariablePicker,
|
|
14
|
+
type TemplateCompletionProvider,
|
|
15
|
+
type VariableNode,
|
|
16
|
+
} from "@checkstack/ui";
|
|
17
|
+
import type { ConditionInput } from "@checkstack/automation-common";
|
|
18
|
+
|
|
19
|
+
type CombinatorKind = "expr" | "and" | "or" | "not";
|
|
20
|
+
|
|
21
|
+
function kindOf(condition: ConditionInput): CombinatorKind {
|
|
22
|
+
if (typeof condition === "string") return "expr";
|
|
23
|
+
if ("and" in condition) return "and";
|
|
24
|
+
if ("or" in condition) return "or";
|
|
25
|
+
return "not";
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function defaultForKind(kind: CombinatorKind): ConditionInput {
|
|
29
|
+
switch (kind) {
|
|
30
|
+
case "expr": {
|
|
31
|
+
return "";
|
|
32
|
+
}
|
|
33
|
+
case "and": {
|
|
34
|
+
return { and: [""] };
|
|
35
|
+
}
|
|
36
|
+
case "or": {
|
|
37
|
+
return { or: [""] };
|
|
38
|
+
}
|
|
39
|
+
case "not": {
|
|
40
|
+
return { not: "" };
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface ConditionEditorProps {
|
|
46
|
+
value: ConditionInput;
|
|
47
|
+
onChange: (next: ConditionInput) => void;
|
|
48
|
+
variableNodes: VariableNode[];
|
|
49
|
+
/**
|
|
50
|
+
* Staged completion provider (expression mode) for the inline
|
|
51
|
+
* expression input. Conditions are bare expressions — no `{{ }}`
|
|
52
|
+
* wrapper — so this is the `expressionCompletion` from
|
|
53
|
+
* `useVariableScope`. The hierarchical `variableNodes` powers the
|
|
54
|
+
* explicit "fx" picker.
|
|
55
|
+
*/
|
|
56
|
+
completionProvider: TemplateCompletionProvider;
|
|
57
|
+
/** Render without the wrapping card — used when inlining inside an action card. */
|
|
58
|
+
bare?: boolean;
|
|
59
|
+
depth?: number;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Recursive editor over the `ConditionInput` discriminated union
|
|
64
|
+
* (`string | { and } | { or } | { not }`). Each level renders:
|
|
65
|
+
*
|
|
66
|
+
* - A kind selector ("expression" | "and" | "or" | "not").
|
|
67
|
+
* - The matching body:
|
|
68
|
+
* expr → TemplateValueInput with `{{` autocomplete + the
|
|
69
|
+
* VariablePicker "fx" trigger inline.
|
|
70
|
+
* and / or → list of child conditions with add/remove buttons,
|
|
71
|
+
* each child rendered through this same component.
|
|
72
|
+
* not → single child condition.
|
|
73
|
+
*
|
|
74
|
+
* No depth cap — operator can nest arbitrarily, mirroring the runtime.
|
|
75
|
+
*/
|
|
76
|
+
export const ConditionEditor: React.FC<ConditionEditorProps> = ({
|
|
77
|
+
value,
|
|
78
|
+
onChange,
|
|
79
|
+
variableNodes,
|
|
80
|
+
completionProvider,
|
|
81
|
+
bare,
|
|
82
|
+
depth = 0,
|
|
83
|
+
}) => {
|
|
84
|
+
const kind = kindOf(value);
|
|
85
|
+
|
|
86
|
+
const body = (
|
|
87
|
+
<div className="space-y-2">
|
|
88
|
+
<div className="flex items-center gap-2">
|
|
89
|
+
<Select
|
|
90
|
+
value={kind}
|
|
91
|
+
onValueChange={(next) =>
|
|
92
|
+
onChange(defaultForKind(next as CombinatorKind))
|
|
93
|
+
}
|
|
94
|
+
>
|
|
95
|
+
<SelectTrigger className="h-7 w-32 text-xs">
|
|
96
|
+
<SelectValue />
|
|
97
|
+
</SelectTrigger>
|
|
98
|
+
<SelectContent>
|
|
99
|
+
<SelectItem value="expr">expression</SelectItem>
|
|
100
|
+
<SelectItem value="and">and</SelectItem>
|
|
101
|
+
<SelectItem value="or">or</SelectItem>
|
|
102
|
+
<SelectItem value="not">not</SelectItem>
|
|
103
|
+
</SelectContent>
|
|
104
|
+
</Select>
|
|
105
|
+
{kind === "expr" && (
|
|
106
|
+
<VariablePicker
|
|
107
|
+
scope={variableNodes}
|
|
108
|
+
onSelect={(path) => {
|
|
109
|
+
// Conditions are bare expressions — insert the raw path,
|
|
110
|
+
// not a `{{ … }}`-wrapped reference.
|
|
111
|
+
const before = typeof value === "string" ? value : "";
|
|
112
|
+
const sep = before.length > 0 && !before.endsWith(" ") ? " " : "";
|
|
113
|
+
onChange(`${before}${sep}${path}`);
|
|
114
|
+
}}
|
|
115
|
+
/>
|
|
116
|
+
)}
|
|
117
|
+
</div>
|
|
118
|
+
|
|
119
|
+
{kind === "expr" && (
|
|
120
|
+
<TemplateValueInput
|
|
121
|
+
value={typeof value === "string" ? value : ""}
|
|
122
|
+
onChange={(next) => onChange(next)}
|
|
123
|
+
placeholder="trigger.payload.severity == "high""
|
|
124
|
+
completionProvider={completionProvider}
|
|
125
|
+
/>
|
|
126
|
+
)}
|
|
127
|
+
|
|
128
|
+
{(kind === "and" || kind === "or") && (
|
|
129
|
+
<CombinatorList
|
|
130
|
+
kind={kind}
|
|
131
|
+
children={
|
|
132
|
+
kind === "and"
|
|
133
|
+
? (value as { and: ConditionInput[] }).and
|
|
134
|
+
: (value as { or: ConditionInput[] }).or
|
|
135
|
+
}
|
|
136
|
+
onChange={(nextChildren) =>
|
|
137
|
+
onChange(
|
|
138
|
+
kind === "and"
|
|
139
|
+
? { and: nextChildren }
|
|
140
|
+
: { or: nextChildren },
|
|
141
|
+
)
|
|
142
|
+
}
|
|
143
|
+
variableNodes={variableNodes}
|
|
144
|
+
completionProvider={completionProvider}
|
|
145
|
+
depth={depth + 1}
|
|
146
|
+
/>
|
|
147
|
+
)}
|
|
148
|
+
|
|
149
|
+
{kind === "not" && (
|
|
150
|
+
<ConditionEditor
|
|
151
|
+
value={(value as { not: ConditionInput }).not}
|
|
152
|
+
onChange={(next) => onChange({ not: next })}
|
|
153
|
+
variableNodes={variableNodes}
|
|
154
|
+
completionProvider={completionProvider}
|
|
155
|
+
bare
|
|
156
|
+
depth={depth + 1}
|
|
157
|
+
/>
|
|
158
|
+
)}
|
|
159
|
+
</div>
|
|
160
|
+
);
|
|
161
|
+
|
|
162
|
+
if (bare) return body;
|
|
163
|
+
return (
|
|
164
|
+
<Card>
|
|
165
|
+
<CardContent className="p-3">{body}</CardContent>
|
|
166
|
+
</Card>
|
|
167
|
+
);
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
const CombinatorList: React.FC<{
|
|
171
|
+
kind: "and" | "or";
|
|
172
|
+
children: ConditionInput[];
|
|
173
|
+
onChange: (next: ConditionInput[]) => void;
|
|
174
|
+
variableNodes: VariableNode[];
|
|
175
|
+
completionProvider: TemplateCompletionProvider;
|
|
176
|
+
depth: number;
|
|
177
|
+
}> = ({ children: items, onChange, variableNodes, completionProvider, depth }) => (
|
|
178
|
+
<div className="space-y-2 border-l border-border pl-3">
|
|
179
|
+
{items.map((child, index) => (
|
|
180
|
+
<div key={index} className="flex items-start gap-2">
|
|
181
|
+
<div className="flex-1">
|
|
182
|
+
<ConditionEditor
|
|
183
|
+
value={child}
|
|
184
|
+
onChange={(next) => {
|
|
185
|
+
const nextChildren = [...items];
|
|
186
|
+
nextChildren[index] = next;
|
|
187
|
+
onChange(nextChildren);
|
|
188
|
+
}}
|
|
189
|
+
variableNodes={variableNodes}
|
|
190
|
+
completionProvider={completionProvider}
|
|
191
|
+
bare
|
|
192
|
+
depth={depth}
|
|
193
|
+
/>
|
|
194
|
+
</div>
|
|
195
|
+
<Button
|
|
196
|
+
type="button"
|
|
197
|
+
variant="ghost"
|
|
198
|
+
size="icon"
|
|
199
|
+
className="h-7 w-7 text-destructive hover:bg-destructive/10"
|
|
200
|
+
onClick={() => onChange(items.filter((_, i) => i !== index))}
|
|
201
|
+
aria-label="Remove condition"
|
|
202
|
+
>
|
|
203
|
+
<Trash2 className="h-3 w-3" />
|
|
204
|
+
</Button>
|
|
205
|
+
</div>
|
|
206
|
+
))}
|
|
207
|
+
<Button
|
|
208
|
+
type="button"
|
|
209
|
+
variant="outline"
|
|
210
|
+
size="sm"
|
|
211
|
+
onClick={() => onChange([...items, ""])}
|
|
212
|
+
className="h-7 text-xs"
|
|
213
|
+
>
|
|
214
|
+
<Plus className="mr-1 h-3 w-3" />
|
|
215
|
+
Add clause
|
|
216
|
+
</Button>
|
|
217
|
+
</div>
|
|
218
|
+
);
|
|
@@ -0,0 +1,89 @@
|
|
|
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
|
+
type TemplateCompletionProvider,
|
|
10
|
+
type VariableNode,
|
|
11
|
+
} from "@checkstack/ui";
|
|
12
|
+
import type { ConditionInput } from "@checkstack/automation-common";
|
|
13
|
+
import { ConditionEditor } from "./ConditionEditor";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Top-level pre-run conditions. Every condition in this list must pass
|
|
17
|
+
* for the actions section to even start — same semantics as Home
|
|
18
|
+
* Assistant's `condition:` block.
|
|
19
|
+
*
|
|
20
|
+
* Each condition reuses the recursive `ConditionEditor`, so combinators
|
|
21
|
+
* (`and` / `or` / `not`) nest the same way they do inside an inline
|
|
22
|
+
* `choose: when` clause.
|
|
23
|
+
*
|
|
24
|
+
* Variable scope at this point is just `trigger.*` — no upstream
|
|
25
|
+
* variables or artifacts exist before the action list starts. We get
|
|
26
|
+
* that scope from the same `useVariableScope` hook by passing the root
|
|
27
|
+
* path; the parent computes it once and threads it down here.
|
|
28
|
+
*/
|
|
29
|
+
export const ConditionsEditor: React.FC<{
|
|
30
|
+
value: ConditionInput[];
|
|
31
|
+
onChange: (next: ConditionInput[]) => void;
|
|
32
|
+
variableNodes: VariableNode[];
|
|
33
|
+
completionProvider: TemplateCompletionProvider;
|
|
34
|
+
disabled?: boolean;
|
|
35
|
+
}> = ({ value, onChange, variableNodes, completionProvider, disabled }) => (
|
|
36
|
+
<Card>
|
|
37
|
+
<CardHeader className="border-b">
|
|
38
|
+
<div className="flex items-center justify-between">
|
|
39
|
+
<CardTitle className="text-base">Conditions</CardTitle>
|
|
40
|
+
<Button
|
|
41
|
+
type="button"
|
|
42
|
+
variant="outline"
|
|
43
|
+
size="sm"
|
|
44
|
+
onClick={() => onChange([...value, ""])}
|
|
45
|
+
disabled={disabled}
|
|
46
|
+
>
|
|
47
|
+
<Plus className="mr-1 h-3 w-3" />
|
|
48
|
+
Add condition
|
|
49
|
+
</Button>
|
|
50
|
+
</div>
|
|
51
|
+
</CardHeader>
|
|
52
|
+
<CardContent className="space-y-2 p-3">
|
|
53
|
+
{value.length === 0 ? (
|
|
54
|
+
<p className="text-xs italic text-muted-foreground">
|
|
55
|
+
No pre-run gating. Add a condition to require it before the
|
|
56
|
+
actions run.
|
|
57
|
+
</p>
|
|
58
|
+
) : (
|
|
59
|
+
value.map((condition, index) => (
|
|
60
|
+
<div key={index} className="flex items-start gap-2">
|
|
61
|
+
<div className="flex-1">
|
|
62
|
+
<ConditionEditor
|
|
63
|
+
value={condition}
|
|
64
|
+
onChange={(next) => {
|
|
65
|
+
const list = [...value];
|
|
66
|
+
list[index] = next;
|
|
67
|
+
onChange(list);
|
|
68
|
+
}}
|
|
69
|
+
variableNodes={variableNodes}
|
|
70
|
+
completionProvider={completionProvider}
|
|
71
|
+
/>
|
|
72
|
+
</div>
|
|
73
|
+
<Button
|
|
74
|
+
type="button"
|
|
75
|
+
variant="ghost"
|
|
76
|
+
size="icon"
|
|
77
|
+
className="h-7 w-7 text-destructive hover:bg-destructive/10"
|
|
78
|
+
onClick={() => onChange(value.filter((_, i) => i !== index))}
|
|
79
|
+
disabled={disabled}
|
|
80
|
+
aria-label="Remove condition"
|
|
81
|
+
>
|
|
82
|
+
<Trash2 className="h-3 w-3" />
|
|
83
|
+
</Button>
|
|
84
|
+
</div>
|
|
85
|
+
))
|
|
86
|
+
)}
|
|
87
|
+
</CardContent>
|
|
88
|
+
</Card>
|
|
89
|
+
);
|