@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.
Files changed (44) hide show
  1. package/CHANGELOG.md +352 -0
  2. package/package.json +13 -9
  3. package/src/components/AutomationGroupCombobox.tsx +133 -0
  4. package/src/editor/ActionEditor.tsx +180 -90
  5. package/src/editor/ActionListEditor.tsx +27 -1
  6. package/src/editor/AddActionDialog.tsx +15 -45
  7. package/src/editor/AddConditionDialog.tsx +86 -0
  8. package/src/editor/AddTriggerDialog.tsx +97 -0
  9. package/src/editor/AutomationDefinitionEditor.tsx +41 -2
  10. package/src/editor/ConditionEditor.tsx +359 -70
  11. package/src/editor/ConditionsEditor.tsx +113 -44
  12. package/src/editor/ItemSheet.tsx +51 -0
  13. package/src/editor/RunReplayPicker.tsx +97 -0
  14. package/src/editor/ScriptServicesBooter.tsx +53 -0
  15. package/src/editor/ScriptTestRenderer.tsx +150 -0
  16. package/src/editor/SystemEntityPicker.test.ts +37 -0
  17. package/src/editor/SystemEntityPicker.tsx +109 -0
  18. package/src/editor/TriggersEditor.tsx +345 -137
  19. package/src/editor/action-helpers.test.ts +107 -0
  20. package/src/editor/action-helpers.ts +72 -0
  21. package/src/editor/action-leaf-cards.tsx +98 -1
  22. package/src/editor/condition-kind.test.ts +126 -0
  23. package/src/editor/condition-kind.ts +130 -0
  24. package/src/editor/item-summary.test.ts +171 -0
  25. package/src/editor/item-summary.ts +210 -0
  26. package/src/editor/picker-dialog.tsx +156 -0
  27. package/src/editor/registry-context.tsx +9 -2
  28. package/src/editor/script-actions.test.ts +184 -0
  29. package/src/editor/script-actions.ts +146 -0
  30. package/src/editor/system-entity-picker.logic.ts +23 -0
  31. package/src/editor/template-completion.test.ts +22 -3
  32. package/src/editor/template-completion.ts +16 -8
  33. package/src/editor/template-helpers.ts +4 -0
  34. package/src/editor/trigger-helpers.test.ts +28 -0
  35. package/src/editor/trigger-helpers.ts +17 -0
  36. package/src/editor/useScriptDiagnostics.ts +108 -0
  37. package/src/index.tsx +2 -0
  38. package/src/pages/AutomationEditPage.tsx +95 -47
  39. package/src/pages/AutomationListPage.tsx +172 -123
  40. package/src/pages/automation-grouping.test.ts +86 -0
  41. package/src/pages/automation-grouping.ts +65 -0
  42. package/src/script-context.test.ts +142 -1
  43. package/src/script-context.ts +115 -0
  44. 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
- Button,
3
+ ActionCard,
4
+ type ActionCardMenuItem,
5
5
  Card,
6
6
  CardContent,
7
7
  CardHeader,
@@ -10,7 +10,12 @@ import {
10
10
  type VariableNode,
11
11
  } from "@checkstack/ui";
12
12
  import type { ConditionInput } from "@checkstack/automation-common";
13
+ import { AddConditionDialog } from "./AddConditionDialog";
13
14
  import { ConditionEditor } from "./ConditionEditor";
15
+ import { defaultForKind } from "./condition-kind";
16
+ import { ItemSheet } from "./ItemSheet";
17
+ import { useConditionIssues } from "./editor-validation";
18
+ import { summarizeCondition } from "./item-summary";
14
19
 
15
20
  /**
16
21
  * Top-level pre-run conditions. Every condition in this list must pass
@@ -35,55 +40,119 @@ export const ConditionsEditor: React.FC<{
35
40
  }> = ({ value, onChange, variableNodes, completionProvider, disabled }) => (
36
41
  <Card>
37
42
  <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>
43
+ <CardTitle className="text-base">Conditions</CardTitle>
51
44
  </CardHeader>
52
45
  <CardContent className="space-y-2 p-3">
53
- {value.length === 0 ? (
46
+ {value.length === 0 && (
54
47
  <p className="text-xs italic text-muted-foreground">
55
48
  No pre-run gating. Add a condition to require it before the
56
49
  actions run.
57
50
  </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
51
  )}
52
+ {value.map((condition, index) => (
53
+ <ConditionCard
54
+ key={index}
55
+ index={index}
56
+ value={condition}
57
+ onChange={(next) => {
58
+ const list = [...value];
59
+ list[index] = next;
60
+ onChange(list);
61
+ }}
62
+ onRemove={() => onChange(value.filter((_, i) => i !== index))}
63
+ onDuplicate={() =>
64
+ // Conditions carry no id, so a structural clone (inserted
65
+ // directly after the original) is all that's needed.
66
+ onChange([
67
+ ...value.slice(0, index + 1),
68
+ structuredClone(condition),
69
+ ...value.slice(index + 1),
70
+ ])
71
+ }
72
+ variableNodes={variableNodes}
73
+ completionProvider={completionProvider}
74
+ disabled={disabled}
75
+ />
76
+ ))}
77
+ <AddConditionDialog
78
+ disabled={disabled}
79
+ onAdd={(kind) => onChange([...value, defaultForKind(kind)])}
80
+ />
87
81
  </CardContent>
88
82
  </Card>
89
83
  );
84
+
85
+ const ConditionCard: React.FC<{
86
+ index: number;
87
+ value: ConditionInput;
88
+ onChange: (next: ConditionInput) => void;
89
+ onRemove: () => void;
90
+ onDuplicate: () => void;
91
+ variableNodes: VariableNode[];
92
+ completionProvider: TemplateCompletionProvider;
93
+ disabled?: boolean;
94
+ }> = ({
95
+ index,
96
+ value,
97
+ onChange,
98
+ onRemove,
99
+ onDuplicate,
100
+ variableNodes,
101
+ completionProvider,
102
+ disabled,
103
+ }) => {
104
+ const issues = useConditionIssues(index);
105
+ const [sheetOpen, setSheetOpen] = React.useState(false);
106
+
107
+ React.useEffect(() => {
108
+ if (issues.length > 0) setSheetOpen(true);
109
+ }, [issues.length]);
110
+
111
+ const summary = summarizeCondition(value);
112
+ const title = `Condition ${index + 1}`;
113
+
114
+ const menuItems: ActionCardMenuItem[] = disabled
115
+ ? []
116
+ : [
117
+ { label: "Duplicate", icon: "Copy", onClick: onDuplicate },
118
+ {
119
+ label: "Delete",
120
+ icon: "Trash2",
121
+ onClick: onRemove,
122
+ variant: "destructive",
123
+ },
124
+ ];
125
+
126
+ return (
127
+ <>
128
+ <ActionCard
129
+ id={`condition-${index}`}
130
+ title={title}
131
+ summary={summary}
132
+ category="Condition"
133
+ icon="Funnel"
134
+ onOpenSheet={() => setSheetOpen(true)}
135
+ actions={menuItems}
136
+ errors={issues}
137
+ />
138
+ <ItemSheet
139
+ open={sheetOpen}
140
+ onOpenChange={setSheetOpen}
141
+ title={title}
142
+ description="Pre-run condition"
143
+ >
144
+ <ConditionEditor
145
+ value={value}
146
+ onChange={onChange}
147
+ variableNodes={variableNodes}
148
+ completionProvider={completionProvider}
149
+ bare
150
+ // Kind is chosen up front via AddConditionDialog; to change it the
151
+ // operator deletes + re-adds, matching how actions work. Nested
152
+ // clauses keep their inline selector (this prop isn't threaded down).
153
+ hideKindSelector
154
+ />
155
+ </ItemSheet>
156
+ </>
157
+ );
158
+ };
@@ -0,0 +1,51 @@
1
+ import React from "react";
2
+ import {
3
+ Sheet,
4
+ SheetBody,
5
+ SheetContent,
6
+ SheetDescription,
7
+ SheetHeader,
8
+ SheetTitle,
9
+ } from "@checkstack/ui";
10
+
11
+ /**
12
+ * Side-sheet host for a single editor item's full configuration.
13
+ *
14
+ * The collapsed summary cards (actions, triggers, conditions) render only a
15
+ * compact row; clicking opens this right-side sheet containing the item's
16
+ * live config form. The form edits the same `value`/`onChange` the inline
17
+ * editor used, so closing the sheet keeps the changes (they're already
18
+ * applied) - there is no draft/commit step.
19
+ *
20
+ * Composite actions nest `ItemSheet`s: a child card inside the parent's sheet
21
+ * body opens its own sheet, which stacks on top via Radix Dialog's portal +
22
+ * overlay. The z-index/overlay layering is handled by the `Sheet` primitive.
23
+ */
24
+ export const ItemSheet: React.FC<{
25
+ open: boolean;
26
+ onOpenChange: (open: boolean) => void;
27
+ title: string;
28
+ description?: string;
29
+ /** Sheet width. Composite/provider bodies want room, so default to `lg`. */
30
+ size?: "default" | "lg" | "xl" | "full";
31
+ children: React.ReactNode;
32
+ }> = ({ open, onOpenChange, title, description, size = "lg", children }) => (
33
+ <Sheet open={open} onOpenChange={onOpenChange}>
34
+ <SheetContent
35
+ size={size}
36
+ // Stop the click that bubbles from a nested card/menu inside the sheet
37
+ // from being interpreted as a header click on the card behind it.
38
+ onClick={(event) => event.stopPropagation()}
39
+ >
40
+ <SheetHeader>
41
+ <SheetTitle className="truncate">{title}</SheetTitle>
42
+ {description && (
43
+ <SheetDescription className="truncate">
44
+ {description}
45
+ </SheetDescription>
46
+ )}
47
+ </SheetHeader>
48
+ <SheetBody className="space-y-4">{children}</SheetBody>
49
+ </SheetContent>
50
+ </Sheet>
51
+ );
@@ -0,0 +1,97 @@
1
+ import React from "react";
2
+ import { usePluginClient } from "@checkstack/frontend-api";
3
+ import {
4
+ Select,
5
+ SelectContent,
6
+ SelectItem,
7
+ SelectTrigger,
8
+ SelectValue,
9
+ } from "@checkstack/ui";
10
+ import { AutomationApi } from "@checkstack/automation-common";
11
+ import { extractErrorMessage } from "@checkstack/common";
12
+
13
+ export interface RunReplayPickerProps {
14
+ /** Automation whose runs populate the picker. */
15
+ automationId: string;
16
+ /**
17
+ * Called with the reconstructed sample-context JSON once a run is
18
+ * selected and its scope is fetched. The parent applies it to the
19
+ * `ContextSampleEditor`.
20
+ */
21
+ onLoad: (sampleContextJson: string) => void;
22
+ /** Surfaces a warning when the picked run's durable scope was cleared. */
23
+ onScopeSnapshotMissing?: () => void;
24
+ }
25
+
26
+ /**
27
+ * "Load from run" dropdown for the script-test panel. Lists this
28
+ * automation's recent runs; selecting one fetches its reconstructed
29
+ * scope via `getRunScopeForReplay` and hands the JSON to `onLoad`.
30
+ *
31
+ * Lives in automation-frontend (owns the RPC); `@checkstack/ui` stays
32
+ * plugin-agnostic and only renders it through the `runPicker` slot.
33
+ */
34
+ export const RunReplayPicker: React.FC<RunReplayPickerProps> = ({
35
+ automationId,
36
+ onLoad,
37
+ onScopeSnapshotMissing,
38
+ }) => {
39
+ const client = usePluginClient(AutomationApi);
40
+ const [selectedRunId, setSelectedRunId] = React.useState<string | null>(null);
41
+
42
+ const runsQuery = client.listRuns.useQuery(
43
+ { automationId, limit: 25 },
44
+ { enabled: Boolean(automationId) },
45
+ );
46
+
47
+ const replayQuery = client.getRunScopeForReplay.useQuery(
48
+ { runId: selectedRunId ?? "" },
49
+ { enabled: Boolean(selectedRunId), gcTime: 0 },
50
+ );
51
+
52
+ // Apply the fetched scope once it resolves for the selected run.
53
+ React.useEffect(() => {
54
+ if (!selectedRunId || !replayQuery.data) return;
55
+ onLoad(JSON.stringify(replayQuery.data.context, null, 2));
56
+ if (!replayQuery.data.scopeSnapshotAvailable) {
57
+ onScopeSnapshotMissing?.();
58
+ }
59
+ // Reset so the same run can be re-picked later.
60
+ setSelectedRunId(null);
61
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- apply once per fetch; callbacks are stable enough and re-running on their identity would double-apply
62
+ }, [selectedRunId, replayQuery.data]);
63
+
64
+ const runs = runsQuery.data?.items ?? [];
65
+
66
+ return (
67
+ <Select
68
+ value=""
69
+ onValueChange={(runId) => setSelectedRunId(runId)}
70
+ disabled={runs.length === 0 || replayQuery.isFetching}
71
+ >
72
+ <SelectTrigger className="h-7 w-[180px] text-xs">
73
+ <SelectValue
74
+ placeholder={
75
+ replayQuery.isFetching
76
+ ? "Loading…"
77
+ : runs.length === 0
78
+ ? "No runs yet"
79
+ : "Load from run"
80
+ }
81
+ />
82
+ </SelectTrigger>
83
+ <SelectContent>
84
+ {runs.map((run) => (
85
+ <SelectItem key={run.id} value={run.id}>
86
+ {new Date(run.startedAt).toLocaleString()} — {run.status}
87
+ </SelectItem>
88
+ ))}
89
+ </SelectContent>
90
+ </Select>
91
+ );
92
+ };
93
+
94
+ /** Format a replay-load error for surfacing in the panel. */
95
+ export function formatReplayError(error: unknown): string {
96
+ return `Failed to load run scope: ${extractErrorMessage(error)}`;
97
+ }
@@ -0,0 +1,53 @@
1
+ import React from "react";
2
+ import {
3
+ CodeEditor,
4
+ areVscodeServicesReady,
5
+ onVscodeServicesReady,
6
+ } from "@checkstack/ui";
7
+
8
+ /**
9
+ * Boots the shared monaco-vscode services so the headless script validator can
10
+ * run from the moment an automation is opened - WITHOUT waiting for the
11
+ * operator to expand a script action card.
12
+ *
13
+ * Why this exists: the validator must not initialize the monaco-vscode services
14
+ * itself (that collides with the editor wrapper's one-time init and throws
15
+ * "Services are already initialized"). Only a real editor may init them. So
16
+ * when an automation already contains inline scripts, we mount ONE tiny,
17
+ * offscreen, read-only editor purely to trigger that init. Its
18
+ * `onEditorStartDone` flips the global "services ready" flag, the validator's
19
+ * subscription fires, and every script - collapsed cards included - validates.
20
+ *
21
+ * Once services are ready the booter unmounts (it has done its job), so there's
22
+ * no lingering hidden editor. It is only rendered when the definition actually
23
+ * has a script action, so automations with none never pay the Monaco load.
24
+ */
25
+ export const ScriptServicesBooter: React.FC = () => {
26
+ const [ready, setReady] = React.useState(() => areVscodeServicesReady());
27
+
28
+ React.useEffect(() => {
29
+ if (ready) return;
30
+ return onVscodeServicesReady(() => setReady(true));
31
+ }, [ready]);
32
+
33
+ if (ready) return null;
34
+
35
+ return (
36
+ <div
37
+ aria-hidden
38
+ // Pulled far offscreen with a tiny footprint so it never affects layout
39
+ // or interaction; it exists only to start the editor services.
40
+ className="pointer-events-none fixed left-[-9999px] top-0 h-[2px] w-[2px] overflow-hidden opacity-0"
41
+ >
42
+ <CodeEditor
43
+ id="automation-script-services-booter"
44
+ language="typescript"
45
+ value=""
46
+ onChange={() => {}}
47
+ minHeight="1px"
48
+ allowPopout={false}
49
+ readOnly
50
+ />
51
+ </div>
52
+ );
53
+ };
@@ -0,0 +1,150 @@
1
+ import React from "react";
2
+ import { usePluginClient } from "@checkstack/frontend-api";
3
+ import {
4
+ ScriptTestPanel,
5
+ ContextSampleEditor,
6
+ type ScriptTestRenderer,
7
+ type ScriptTestPanelResult,
8
+ type ScriptTestRunArgs,
9
+ } from "@checkstack/ui";
10
+ import { AutomationApi } from "@checkstack/automation-common";
11
+ import { extractErrorMessage } from "@checkstack/common";
12
+ import { useAutomationRegistry } from "./registry-context";
13
+ import { RunReplayPicker } from "./RunReplayPicker";
14
+
15
+ /**
16
+ * Default sample context for a freshly-opened test panel. Auto-seeded so
17
+ * operators see a runnable example instead of a blank slate. Mirrors the
18
+ * `context` shape `run_script` exposes / `run_shell` flattens.
19
+ */
20
+ const DEFAULT_SAMPLE_CONTEXT = `{
21
+ "trigger": {
22
+ "event": "incident.created",
23
+ "payload": {
24
+ "id": "INC-42",
25
+ "title": "API latency spike",
26
+ "severity": "high"
27
+ }
28
+ },
29
+ "artifacts": {},
30
+ "var": {}
31
+ }`;
32
+
33
+ const TIMEOUT_MS = 30_000;
34
+
35
+ interface AutomationScriptTestPanelProps {
36
+ kind: "typescript" | "shell";
37
+ script: string;
38
+ /**
39
+ * The action's declared secret → env mapping (the sibling `x-secret-env`
40
+ * field). Forwarded to `testScript` so the test injects
41
+ * `__SECRET_<NAME>__` placeholders (or operator overrides) — real secret
42
+ * values are NEVER resolved in the test path.
43
+ */
44
+ secretEnv?: Record<string, string>;
45
+ }
46
+
47
+ /**
48
+ * Self-contained test panel for an automation `run_script` / `run_shell`
49
+ * action config field. Owns its own editable sample-context state and the
50
+ * `testScript` mutation, so it can be returned from a {@link ScriptTestRenderer}
51
+ * without violating the rules of hooks.
52
+ */
53
+ const AutomationScriptTestPanel: React.FC<AutomationScriptTestPanelProps> = ({
54
+ kind,
55
+ script,
56
+ secretEnv,
57
+ }) => {
58
+ const client = usePluginClient(AutomationApi);
59
+ const { automationId } = useAutomationRegistry();
60
+ const testMutation = client.testScript.useMutation();
61
+ const [sampleContext, setSampleContext] = React.useState(
62
+ DEFAULT_SAMPLE_CONTEXT,
63
+ );
64
+ const [snapshotWarning, setSnapshotWarning] = React.useState(false);
65
+
66
+ const handleRun = React.useCallback(
67
+ async ({
68
+ secretOverrides,
69
+ }: ScriptTestRunArgs): Promise<ScriptTestPanelResult> => {
70
+ let context: Record<string, unknown> | undefined;
71
+ if (sampleContext.trim().length > 0) {
72
+ try {
73
+ context = JSON.parse(sampleContext) as Record<string, unknown>;
74
+ } catch (error) {
75
+ return {
76
+ stdout: "",
77
+ stderr: "",
78
+ durationMs: 0,
79
+ timedOut: false,
80
+ error: `Sample context is not valid JSON: ${extractErrorMessage(error)}`,
81
+ };
82
+ }
83
+ }
84
+ return testMutation.mutateAsync({
85
+ kind,
86
+ script,
87
+ context,
88
+ // The action's declared secrets → placeholders/overrides in the test
89
+ // run. Real values are never resolved here (decision 4).
90
+ ...(secretEnv ? { secretEnv } : {}),
91
+ ...(secretOverrides ? { secretOverrides } : {}),
92
+ timeoutMs: TIMEOUT_MS,
93
+ });
94
+ },
95
+ [testMutation, kind, script, sampleContext, secretEnv],
96
+ );
97
+
98
+ const note = snapshotWarning
99
+ ? "Loaded trigger + artifacts from the run. Variables / loop state were not available (the run's durable scope was already cleared). Runs on the central backend; real satellite runs may differ."
100
+ : undefined;
101
+
102
+ return (
103
+ <ScriptTestPanel
104
+ onRun={handleRun}
105
+ disabled={script.trim().length === 0}
106
+ secretEnv={secretEnv}
107
+ note={note}
108
+ contextEditor={
109
+ <ContextSampleEditor
110
+ value={sampleContext}
111
+ onChange={(next) => {
112
+ setSampleContext(next);
113
+ setSnapshotWarning(false);
114
+ }}
115
+ runPicker={
116
+ automationId ? (
117
+ <RunReplayPicker
118
+ automationId={automationId}
119
+ onLoad={(json) => {
120
+ setSampleContext(json);
121
+ setSnapshotWarning(false);
122
+ }}
123
+ onScopeSnapshotMissing={() => setSnapshotWarning(true)}
124
+ />
125
+ ) : undefined
126
+ }
127
+ />
128
+ }
129
+ />
130
+ );
131
+ };
132
+
133
+ /**
134
+ * Build the {@link ScriptTestRenderer} passed to `DynamicForm` for the
135
+ * automation action editor. Renders an {@link AutomationScriptTestPanel}
136
+ * beneath any `x-script-testable` script field.
137
+ */
138
+ export const automationScriptTestRenderer: ScriptTestRenderer = ({
139
+ fieldId,
140
+ kind,
141
+ script,
142
+ secretEnv,
143
+ }) => (
144
+ <AutomationScriptTestPanel
145
+ key={`${fieldId}-test`}
146
+ kind={kind}
147
+ script={script}
148
+ secretEnv={secretEnv}
149
+ />
150
+ );
@@ -0,0 +1,37 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import { shouldStartManual } from "./system-entity-picker.logic";
3
+
4
+ describe("shouldStartManual", () => {
5
+ const known = new Set(["payments-api", "billing"]);
6
+
7
+ it("uses the picker for a blank value", () => {
8
+ expect(shouldStartManual({ value: "", knownSystemIds: known })).toBe(false);
9
+ });
10
+
11
+ it("uses the picker for a known system id", () => {
12
+ expect(
13
+ shouldStartManual({ value: "payments-api", knownSystemIds: known }),
14
+ ).toBe(false);
15
+ });
16
+
17
+ it("falls back to manual entry for an unknown id (preserves round-trip)", () => {
18
+ expect(
19
+ shouldStartManual({ value: "future-system", knownSystemIds: known }),
20
+ ).toBe(true);
21
+ });
22
+
23
+ it("falls back to manual entry for a template", () => {
24
+ expect(
25
+ shouldStartManual({
26
+ value: "{{ trigger.payload.system }}",
27
+ knownSystemIds: known,
28
+ }),
29
+ ).toBe(true);
30
+ });
31
+
32
+ it("treats a template as manual even against an empty catalog", () => {
33
+ expect(
34
+ shouldStartManual({ value: "{{ x }}", knownSystemIds: new Set() }),
35
+ ).toBe(true);
36
+ });
37
+ });
@@ -0,0 +1,109 @@
1
+ import React from "react";
2
+ import { Pencil, List } from "lucide-react";
3
+ import { usePluginClient } from "@checkstack/frontend-api";
4
+ import { CatalogApi } from "@checkstack/catalog-common";
5
+ import { Button, Input } from "@checkstack/ui";
6
+ import { ItemPicker } from "./ItemPicker";
7
+ import { shouldStartManual } from "./system-entity-picker.logic";
8
+
9
+ export interface SystemEntityPickerProps {
10
+ /** Current entity value — a catalog system id, or a free-form id / template. */
11
+ value: string;
12
+ onChange: (next: string) => void;
13
+ disabled?: boolean;
14
+ }
15
+
16
+ /**
17
+ * Live system picker for a `state` condition's `entity`. Backed by the
18
+ * catalog `getSystems` RPC so an operator picks from the registered systems
19
+ * instead of typing a raw id.
20
+ *
21
+ * Keeps a manual-entry fallback: an id not present in the catalog (e.g. a
22
+ * system that will exist at run time, or a `{{ … }}` template) must still
23
+ * round-trip losslessly, so the picker switches to a plain text input when
24
+ * the current value isn't a known system id, and an explicit toggle lets the
25
+ * operator force manual entry. The value is always a bare string either way,
26
+ * so the saved `definition` is unchanged.
27
+ */
28
+ export const SystemEntityPicker: React.FC<SystemEntityPickerProps> = ({
29
+ value,
30
+ onChange,
31
+ disabled,
32
+ }) => {
33
+ const catalogClient = usePluginClient(CatalogApi);
34
+ const { data } = catalogClient.getSystems.useQuery({});
35
+ const systems = React.useMemo(() => data?.systems ?? [], [data]);
36
+
37
+ const items = React.useMemo(
38
+ () =>
39
+ systems.map((system) => ({
40
+ id: system.id,
41
+ label: system.name,
42
+ description: system.id,
43
+ })),
44
+ [systems],
45
+ );
46
+
47
+ // Drop to manual entry for templates or ids that aren't (yet) in the
48
+ // catalog so the value still round-trips; an explicit toggle also forces
49
+ // it. A blank value defaults to the picker (the common case).
50
+ const [manual, setManual] = React.useState(() =>
51
+ shouldStartManual({
52
+ value,
53
+ knownSystemIds: new Set(systems.map((system) => system.id)),
54
+ }),
55
+ );
56
+
57
+ if (manual) {
58
+ return (
59
+ <div className="flex items-center gap-2">
60
+ <Input
61
+ className="font-mono text-xs"
62
+ value={value}
63
+ placeholder="payments-api or {{ trigger.payload.system }}"
64
+ onChange={(event) => onChange(event.target.value)}
65
+ disabled={disabled}
66
+ />
67
+ <Button
68
+ type="button"
69
+ variant="ghost"
70
+ size="icon"
71
+ className="h-8 w-8 shrink-0"
72
+ onClick={() => setManual(false)}
73
+ disabled={disabled}
74
+ aria-label="Pick from catalog systems"
75
+ title="Pick from catalog systems"
76
+ >
77
+ <List className="h-3.5 w-3.5" />
78
+ </Button>
79
+ </div>
80
+ );
81
+ }
82
+
83
+ return (
84
+ <div className="flex items-center gap-2">
85
+ <div className="flex-1">
86
+ <ItemPicker
87
+ items={items}
88
+ value={value || undefined}
89
+ onSelect={onChange}
90
+ placeholder="Pick a system"
91
+ emptyText="No systems in the catalog"
92
+ disabled={disabled}
93
+ />
94
+ </div>
95
+ <Button
96
+ type="button"
97
+ variant="ghost"
98
+ size="icon"
99
+ className="h-8 w-8 shrink-0"
100
+ onClick={() => setManual(true)}
101
+ disabled={disabled}
102
+ aria-label="Enter a system id or template manually"
103
+ title="Enter a system id or template manually"
104
+ >
105
+ <Pencil className="h-3.5 w-3.5" />
106
+ </Button>
107
+ </div>
108
+ );
109
+ };