@checkstack/automation-frontend 0.2.0 → 0.4.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 (46) hide show
  1. package/CHANGELOG.md +496 -0
  2. package/package.json +18 -13
  3. package/src/components/AutomationGroupCombobox.tsx +133 -0
  4. package/src/components/RunAsServiceAccountPicker.tsx +170 -0
  5. package/src/editor/ActionEditor.tsx +180 -90
  6. package/src/editor/ActionListEditor.tsx +27 -1
  7. package/src/editor/AddActionDialog.tsx +15 -45
  8. package/src/editor/AddConditionDialog.tsx +86 -0
  9. package/src/editor/AddTriggerDialog.tsx +97 -0
  10. package/src/editor/AutomationDefinitionEditor.tsx +41 -2
  11. package/src/editor/ConditionEditor.tsx +359 -70
  12. package/src/editor/ConditionsEditor.tsx +113 -44
  13. package/src/editor/ItemSheet.tsx +51 -0
  14. package/src/editor/RunReplayPicker.tsx +97 -0
  15. package/src/editor/ScriptServicesBooter.tsx +57 -0
  16. package/src/editor/ScriptTestRenderer.tsx +150 -0
  17. package/src/editor/SystemEntityPicker.test.ts +37 -0
  18. package/src/editor/SystemEntityPicker.tsx +109 -0
  19. package/src/editor/TriggersEditor.tsx +345 -137
  20. package/src/editor/action-helpers.test.ts +107 -0
  21. package/src/editor/action-helpers.ts +72 -0
  22. package/src/editor/action-leaf-cards.tsx +105 -1
  23. package/src/editor/condition-kind.test.ts +126 -0
  24. package/src/editor/condition-kind.ts +130 -0
  25. package/src/editor/item-summary.test.ts +171 -0
  26. package/src/editor/item-summary.ts +210 -0
  27. package/src/editor/picker-dialog.tsx +156 -0
  28. package/src/editor/registry-context.tsx +9 -2
  29. package/src/editor/script-actions.test.ts +184 -0
  30. package/src/editor/script-actions.ts +146 -0
  31. package/src/editor/system-entity-picker.logic.ts +23 -0
  32. package/src/editor/template-completion.test.ts +22 -3
  33. package/src/editor/template-completion.ts +16 -8
  34. package/src/editor/template-helpers.ts +4 -0
  35. package/src/editor/trigger-helpers.test.ts +28 -0
  36. package/src/editor/trigger-helpers.ts +17 -0
  37. package/src/editor/useScriptDiagnostics.ts +108 -0
  38. package/src/index.tsx +26 -24
  39. package/src/pages/AutomationEditPage.tsx +116 -48
  40. package/src/pages/AutomationListPage.tsx +172 -123
  41. package/src/pages/automation-grouping.test.ts +87 -0
  42. package/src/pages/automation-grouping.ts +65 -0
  43. package/src/script-context.test.ts +142 -1
  44. package/src/script-context.ts +115 -0
  45. package/tsconfig.json +15 -0
  46. package/src/components/AutomationMenuItems.tsx +0 -37
@@ -22,6 +22,7 @@ import type {
22
22
  import {
23
23
  assignDefaultIds,
24
24
  collectActionIds,
25
+ duplicateAction,
25
26
  makeEmptyAction,
26
27
  makeProviderAction,
27
28
  } from "./action-helpers";
@@ -128,6 +129,28 @@ export const ActionListEditor: React.FC<ActionListEditorProps> = ({
128
129
  setIds((current) => [...current, nextId()]);
129
130
  };
130
131
 
132
+ const duplicateStep = (index: number): void => {
133
+ const original = value[index];
134
+ if (!original) return;
135
+ // Clone with FRESH ids deduped against every id in the automation so the
136
+ // copy never collides with the original (or any sibling/nested step).
137
+ const taken = collectActionIds(definition.actions);
138
+ const clone = duplicateAction(original, taken);
139
+ // Insert directly after the original, keeping the parallel `ids` array in
140
+ // lockstep so each card's stable React key / drag id stays aligned.
141
+ const nextValue = [
142
+ ...value.slice(0, index + 1),
143
+ clone,
144
+ ...value.slice(index + 1),
145
+ ];
146
+ onChange(nextValue);
147
+ setIds((current) => [
148
+ ...current.slice(0, index + 1),
149
+ nextId(),
150
+ ...current.slice(index + 1),
151
+ ]);
152
+ };
153
+
131
154
  return (
132
155
  <div className="space-y-2">
133
156
  <DndContext
@@ -150,6 +173,7 @@ export const ActionListEditor: React.FC<ActionListEditorProps> = ({
150
173
  onChange(value.filter((_, i) => i !== index));
151
174
  setIds((current) => current.filter((_, i) => i !== index));
152
175
  }}
176
+ onDuplicate={disabled ? undefined : () => duplicateStep(index)}
153
177
  path={childPathAt(index)}
154
178
  definition={definition}
155
179
  disabled={disabled}
@@ -174,10 +198,11 @@ const SortableActionItem: React.FC<{
174
198
  value: ActionInput;
175
199
  onChange: (next: ActionInput) => void;
176
200
  onDelete: () => void;
201
+ onDuplicate?: () => void;
177
202
  path: ActionPath;
178
203
  definition: Parameters<typeof ActionEditor>[0]["definition"];
179
204
  disabled?: boolean;
180
- }> = ({ id, value, onChange, onDelete, path, definition, disabled }) => {
205
+ }> = ({ id, value, onChange, onDelete, onDuplicate, path, definition, disabled }) => {
181
206
  const sortable = useSortable({ id });
182
207
  const style: React.CSSProperties = {
183
208
  transform: CSS.Transform.toString(sortable.transform),
@@ -189,6 +214,7 @@ const SortableActionItem: React.FC<{
189
214
  value={value}
190
215
  onChange={onChange}
191
216
  onDelete={onDelete}
217
+ onDuplicate={onDuplicate}
192
218
  path={path}
193
219
  definition={definition}
194
220
  stableId={id}
@@ -1,17 +1,20 @@
1
1
  import React from "react";
2
- import { Plus, Search, Zap } from "lucide-react";
2
+ import { Zap } from "lucide-react";
3
3
  import {
4
- Button,
5
4
  Dialog,
6
5
  DialogContent,
7
6
  DialogHeader,
8
7
  DialogTitle,
9
8
  DynamicIcon,
10
- Input,
11
9
  TabPanel,
12
10
  Tabs,
13
11
  } from "@checkstack/ui";
14
12
  import { ACTION_KIND_META, type ActionKind } from "./action-helpers";
13
+ import {
14
+ PickerAddButton,
15
+ PickerRow,
16
+ PickerSearchInput,
17
+ } from "./picker-dialog";
15
18
  import { useAutomationRegistry } from "./registry-context";
16
19
 
17
20
  /**
@@ -30,6 +33,7 @@ const BLOCK_KINDS: ActionKind[] = [
30
33
  "stop",
31
34
  "variables",
32
35
  "wait_for_trigger",
36
+ "wait_until",
33
37
  ];
34
38
 
35
39
  const TAB_ITEMS = [
@@ -37,29 +41,6 @@ const TAB_ITEMS = [
37
41
  { id: "blocks", label: "Blocks" },
38
42
  ];
39
43
 
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
44
  export interface AddActionDialogProps {
64
45
  /** Insert a building-block step of the given kind. */
65
46
  onAddKind: (kind: ActionKind) => void;
@@ -128,17 +109,11 @@ export const AddActionDialog: React.FC<AddActionDialogProps> = ({
128
109
 
129
110
  return (
130
111
  <>
131
- <Button
132
- type="button"
133
- variant="outline"
134
- size="sm"
112
+ <PickerAddButton
113
+ label="Add step"
135
114
  disabled={disabled}
136
- className="h-7 text-xs"
137
115
  onClick={() => setOpen(true)}
138
- >
139
- <Plus className="mr-1 h-3 w-3" />
140
- Add step
141
- </Button>
116
+ />
142
117
  <Dialog open={open} onOpenChange={setOpen}>
143
118
  <DialogContent size="lg">
144
119
  <DialogHeader>
@@ -146,16 +121,11 @@ export const AddActionDialog: React.FC<AddActionDialogProps> = ({
146
121
  </DialogHeader>
147
122
 
148
123
  <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>
124
+ <PickerSearchInput
125
+ value={query}
126
+ onChange={setQuery}
127
+ placeholder="Search actions and blocks…"
128
+ />
159
129
 
160
130
  <Tabs items={TAB_ITEMS} activeTab={tab} onTabChange={setTab} />
161
131
 
@@ -0,0 +1,86 @@
1
+ import React from "react";
2
+ import { DynamicIcon } from "@checkstack/ui";
3
+ import {
4
+ CONDITION_KIND_META,
5
+ type ConditionKind,
6
+ type ConditionKindGroup,
7
+ } from "./condition-kind";
8
+ import { PickerDialogShell, PickerRow } from "./picker-dialog";
9
+
10
+ export interface AddConditionDialogProps {
11
+ /** Append a condition of the picked kind (seeded via `defaultForKind`). */
12
+ onAdd: (kind: ConditionKind) => void;
13
+ disabled?: boolean;
14
+ }
15
+
16
+ /** Picker group order — structured first (common case), advanced last. */
17
+ const GROUP_ORDER: ConditionKindGroup[] = ["Structured", "Logical", "Advanced"];
18
+
19
+ const ALL_KINDS = Object.values(CONDITION_KIND_META);
20
+
21
+ /**
22
+ * Home-Assistant-style condition type picker. The operator picks the condition
23
+ * kind up front (grouped by `CONDITION_KIND_META.group`, searchable on label /
24
+ * description); on pick the consumer (`ConditionsEditor`) appends a seeded
25
+ * default for that kind. Mirrors the Actions "Add step" picker but is
26
+ * single-list.
27
+ */
28
+ export const AddConditionDialog: React.FC<AddConditionDialogProps> = ({
29
+ onAdd,
30
+ disabled,
31
+ }) => (
32
+ <PickerDialogShell
33
+ addLabel="Add condition"
34
+ title="Add condition"
35
+ searchPlaceholder="Search conditions…"
36
+ disabled={disabled}
37
+ >
38
+ {({ query, close }) => {
39
+ const filtered = query
40
+ ? ALL_KINDS.filter(
41
+ (meta) =>
42
+ meta.label.toLowerCase().includes(query) ||
43
+ meta.description.toLowerCase().includes(query) ||
44
+ meta.kind.toLowerCase().includes(query),
45
+ )
46
+ : ALL_KINDS;
47
+
48
+ const grouped = GROUP_ORDER.map((group) => ({
49
+ group,
50
+ items: filtered.filter((meta) => meta.group === group),
51
+ })).filter(({ items }) => items.length > 0);
52
+
53
+ if (grouped.length === 0) {
54
+ return (
55
+ <p className="px-1 py-6 text-center text-xs italic text-muted-foreground">
56
+ No matching conditions.
57
+ </p>
58
+ );
59
+ }
60
+
61
+ return (
62
+ <div className="space-y-3">
63
+ {grouped.map(({ group, items }) => (
64
+ <div key={group} className="space-y-1">
65
+ <p className="px-1 text-[10px] font-medium uppercase tracking-wide text-muted-foreground">
66
+ {group}
67
+ </p>
68
+ {items.map((meta) => (
69
+ <PickerRow
70
+ key={meta.kind}
71
+ icon={<DynamicIcon name={meta.icon} className="h-4 w-4" />}
72
+ title={meta.label}
73
+ description={meta.description}
74
+ onClick={() => {
75
+ onAdd(meta.kind);
76
+ close();
77
+ }}
78
+ />
79
+ ))}
80
+ </div>
81
+ ))}
82
+ </div>
83
+ );
84
+ }}
85
+ </PickerDialogShell>
86
+ );
@@ -0,0 +1,97 @@
1
+ import React from "react";
2
+ import { Zap } from "lucide-react";
3
+ import { useAutomationRegistry } from "./registry-context";
4
+ import { PickerDialogShell, PickerRow } from "./picker-dialog";
5
+
6
+ export interface AddTriggerDialogProps {
7
+ /** Append a trigger subscribed to the picked event's qualified id. */
8
+ onAdd: (event: string) => void;
9
+ disabled?: boolean;
10
+ }
11
+
12
+ /**
13
+ * Home-Assistant-style trigger type picker. The operator picks a concrete
14
+ * trigger event (grouped by category, searchable) up front; on pick the
15
+ * consumer (`TriggersEditor`) creates the trigger with a unique default id and
16
+ * appends it. Mirrors the Actions "Add step" picker but is single-list.
17
+ */
18
+ export const AddTriggerDialog: React.FC<AddTriggerDialogProps> = ({
19
+ onAdd,
20
+ disabled,
21
+ }) => {
22
+ const { triggers } = useAutomationRegistry();
23
+
24
+ const items = React.useMemo(
25
+ () =>
26
+ triggers.map((t) => ({
27
+ id: t.qualifiedId,
28
+ label: t.displayName,
29
+ description: t.description,
30
+ category: t.category,
31
+ })),
32
+ [triggers],
33
+ );
34
+
35
+ return (
36
+ <PickerDialogShell
37
+ addLabel="Add trigger"
38
+ title="Add trigger"
39
+ searchPlaceholder="Search triggers…"
40
+ disabled={disabled}
41
+ >
42
+ {({ query, close }) => {
43
+ const filtered = query
44
+ ? items.filter(
45
+ (item) =>
46
+ item.label.toLowerCase().includes(query) ||
47
+ item.id.toLowerCase().includes(query) ||
48
+ item.description?.toLowerCase().includes(query) ||
49
+ item.category.toLowerCase().includes(query),
50
+ )
51
+ : items;
52
+
53
+ const groups = new Map<string, typeof filtered>();
54
+ for (const item of filtered) {
55
+ const list = groups.get(item.category) ?? [];
56
+ list.push(item);
57
+ groups.set(item.category, list);
58
+ }
59
+ const grouped = [...groups.entries()].toSorted(([a], [b]) =>
60
+ a.localeCompare(b),
61
+ );
62
+
63
+ if (grouped.length === 0) {
64
+ return (
65
+ <p className="px-1 py-6 text-center text-xs italic text-muted-foreground">
66
+ No matching triggers.
67
+ </p>
68
+ );
69
+ }
70
+
71
+ return (
72
+ <div className="space-y-3">
73
+ {grouped.map(([category, list]) => (
74
+ <div key={category} className="space-y-1">
75
+ <p className="px-1 text-[10px] font-medium uppercase tracking-wide text-muted-foreground">
76
+ {category}
77
+ </p>
78
+ {list.map((item) => (
79
+ <PickerRow
80
+ key={item.id}
81
+ icon={<Zap className="h-4 w-4" />}
82
+ title={item.label}
83
+ description={item.description}
84
+ onClick={() => {
85
+ onAdd(item.id);
86
+ close();
87
+ }}
88
+ />
89
+ ))}
90
+ </div>
91
+ ))}
92
+ </div>
93
+ );
94
+ }}
95
+ </PickerDialogShell>
96
+ );
97
+ };
@@ -12,11 +12,29 @@ import {
12
12
  import { TriggersEditor } from "./TriggersEditor";
13
13
  import { ConditionsEditor } from "./ConditionsEditor";
14
14
  import { ActionListEditor } from "./ActionListEditor";
15
+ import { ValidationProvider, type DefinitionIssue } from "./editor-validation";
16
+ import { useScriptDiagnostics } from "./useScriptDiagnostics";
17
+ import { collectScriptActions } from "./script-actions";
18
+ import { ScriptServicesBooter } from "./ScriptServicesBooter";
15
19
 
16
20
  export interface AutomationDefinitionEditorProps {
17
21
  value: AutomationDefinition;
18
22
  onChange: (next: AutomationDefinition) => void;
19
23
  disabled?: boolean;
24
+ /**
25
+ * Id of the automation being edited, if it already exists (omitted for
26
+ * a brand-new, unsaved automation). Enables the script-test "Load from
27
+ * run" picker, which lists this automation's prior runs.
28
+ */
29
+ automationId?: string;
30
+ /**
31
+ * Structural validation issues (from the backend `validateDefinition`),
32
+ * merged here with the live, frontend-computed inline-script TYPE issues so
33
+ * both surface through the same per-card badges. Script type-checking lives
34
+ * inside this component because it needs the registry (trigger/action
35
+ * catalog) the `AutomationRegistryProvider` supplies.
36
+ */
37
+ structuralIssues?: DefinitionIssue[];
20
38
  }
21
39
 
22
40
  /**
@@ -31,7 +49,7 @@ export interface AutomationDefinitionEditorProps {
31
49
  export const AutomationDefinitionEditor: React.FC<
32
50
  AutomationDefinitionEditorProps
33
51
  > = (props) => (
34
- <AutomationRegistryProvider>
52
+ <AutomationRegistryProvider automationId={props.automationId}>
35
53
  <AutomationDefinitionProvider definition={props.value}>
36
54
  <EditorBody {...props} />
37
55
  </AutomationDefinitionProvider>
@@ -42,8 +60,26 @@ const EditorBody: React.FC<AutomationDefinitionEditorProps> = ({
42
60
  value,
43
61
  onChange,
44
62
  disabled,
63
+ structuralIssues = [],
45
64
  }) => {
46
- const { loading } = useAutomationRegistry();
65
+ const { loading, actions } = useAutomationRegistry();
66
+
67
+ // Live type-check of every inline script action (collapsed cards included),
68
+ // merged with the backend's structural issues so both drive the same
69
+ // per-card badges via `ValidationProvider`.
70
+ const scriptIssues = useScriptDiagnostics({ definition: value });
71
+ const issues = React.useMemo(
72
+ () => [...structuralIssues, ...scriptIssues],
73
+ [structuralIssues, scriptIssues],
74
+ );
75
+
76
+ // When the automation already contains inline scripts, boot the editor
77
+ // services up-front (a hidden editor) so those scripts validate the moment
78
+ // the automation opens - not only after a card is expanded.
79
+ const hasScriptActions = React.useMemo(
80
+ () => collectScriptActions({ definition: value, actions }).length > 0,
81
+ [value, actions],
82
+ );
47
83
 
48
84
  // Scope at the root path = `trigger.*` only. The conditions editor
49
85
  // uses `variableNodes` for the explicit "fx" tree and
@@ -55,6 +91,8 @@ const EditorBody: React.FC<AutomationDefinitionEditorProps> = ({
55
91
  });
56
92
 
57
93
  return (
94
+ <ValidationProvider issues={issues}>
95
+ {hasScriptActions && <ScriptServicesBooter />}
58
96
  <div className="space-y-4">
59
97
  {loading && (
60
98
  <Card className="border-dashed">
@@ -95,5 +133,6 @@ const EditorBody: React.FC<AutomationDefinitionEditorProps> = ({
95
133
  </CardContent>
96
134
  </Card>
97
135
  </div>
136
+ </ValidationProvider>
98
137
  );
99
138
  };