@checkstack/ui 1.10.0 → 1.12.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 (72) hide show
  1. package/.storybook/main.ts +43 -0
  2. package/CHANGELOG.md +565 -0
  3. package/package.json +15 -7
  4. package/scripts/generate-stdlib-types.ts +25 -2
  5. package/src/components/ActionCard.tsx +309 -0
  6. package/src/components/CodeEditor/CodeEditor.tsx +132 -9
  7. package/src/components/CodeEditor/TypefoxEditor.tsx +1024 -0
  8. package/src/components/CodeEditor/bracketKeyGroups.test.ts +120 -0
  9. package/src/components/CodeEditor/bracketKeyGroups.ts +205 -0
  10. package/src/components/CodeEditor/generateTypeDefinitions.ts +4 -4
  11. package/src/components/CodeEditor/generated/builtin-modules.json +1 -0
  12. package/src/components/CodeEditor/importSpecifiers.test.ts +286 -0
  13. package/src/components/CodeEditor/importSpecifiers.ts +267 -0
  14. package/src/components/CodeEditor/index.ts +26 -0
  15. package/src/components/CodeEditor/monacoTsService.ts +217 -0
  16. package/src/components/CodeEditor/popoutTitle.test.ts +37 -0
  17. package/src/components/CodeEditor/popoutTitle.ts +31 -0
  18. package/src/components/CodeEditor/scriptContext.test.ts +41 -0
  19. package/src/components/CodeEditor/scriptContext.ts +76 -1
  20. package/src/components/CodeEditor/scriptDiagnostics.test.ts +135 -0
  21. package/src/components/CodeEditor/scriptDiagnostics.ts +172 -0
  22. package/src/components/CodeEditor/templateValidation.ts +51 -0
  23. package/src/components/CodeEditor/types.ts +168 -0
  24. package/src/components/CodeEditor/validateJsonTemplate.test.ts +61 -0
  25. package/src/components/CodeEditor/validateJsonTemplate.ts +26 -0
  26. package/src/components/CodeEditor/validateScripts.ts +132 -0
  27. package/src/components/CodeEditor/validateXmlTemplate.test.ts +34 -0
  28. package/src/components/CodeEditor/validateXmlTemplate.ts +35 -0
  29. package/src/components/CodeEditor/validateYamlTemplate.test.ts +39 -0
  30. package/src/components/CodeEditor/validateYamlTemplate.ts +28 -0
  31. package/src/components/Dialog.tsx +32 -11
  32. package/src/components/DurationInput.tsx +121 -0
  33. package/src/components/DynamicForm/DynamicForm.tsx +27 -1
  34. package/src/components/DynamicForm/FormField.tsx +138 -10
  35. package/src/components/DynamicForm/KeyValueEditor.tsx +2 -169
  36. package/src/components/DynamicForm/MultiTypeEditorField.tsx +83 -9
  37. package/src/components/DynamicForm/SecretEnvEditor.tsx +315 -0
  38. package/src/components/DynamicForm/index.ts +6 -0
  39. package/src/components/DynamicForm/secretEnv.logic.test.ts +126 -0
  40. package/src/components/DynamicForm/secretEnv.logic.ts +87 -0
  41. package/src/components/DynamicForm/types.ts +83 -1
  42. package/src/components/DynamicForm/utils.ts +32 -0
  43. package/src/components/Popover.tsx +6 -1
  44. package/src/components/ScriptTestPanel.logic.test.ts +139 -0
  45. package/src/components/ScriptTestPanel.logic.ts +137 -0
  46. package/src/components/ScriptTestPanel.tsx +394 -0
  47. package/src/components/Sheet.tsx +21 -6
  48. package/src/components/TemplateInput.tsx +104 -0
  49. package/src/components/TemplateInputToggle.tsx +111 -0
  50. package/src/components/TemplateValueInput.test.ts +98 -0
  51. package/src/components/TemplateValueInput.tsx +470 -0
  52. package/src/components/TimeOfDayInput.tsx +116 -0
  53. package/src/components/VariablePicker.tsx +271 -0
  54. package/src/components/comboboxInteraction.ts +39 -0
  55. package/src/components/portalContainer.ts +24 -0
  56. package/src/hooks/useInitOnceForKey.test.ts +27 -0
  57. package/src/hooks/useInitOnceForKey.ts +21 -18
  58. package/src/index.ts +9 -0
  59. package/stories/ActionCard.stories.tsx +122 -0
  60. package/stories/Alert.stories.tsx +5 -5
  61. package/stories/CodeEditor.stories.tsx +47 -2
  62. package/stories/DurationInput.stories.tsx +59 -0
  63. package/stories/ScriptTestPanel.stories.tsx +106 -0
  64. package/stories/SecretEnvEditor.stories.tsx +80 -0
  65. package/stories/TemplateInputToggle.stories.tsx +77 -0
  66. package/stories/TemplateValueInput.stories.tsx +65 -0
  67. package/stories/TimeOfDayInput.stories.tsx +34 -0
  68. package/stories/VariablePicker.stories.tsx +109 -0
  69. package/tsconfig.json +1 -0
  70. package/src/components/CodeEditor/MonacoEditor.tsx +0 -616
  71. package/src/components/CodeEditor/monacoStdlib.ts +0 -62
  72. package/src/components/CodeEditor/monacoWorkers.ts +0 -118
@@ -0,0 +1,394 @@
1
+ import React from "react";
2
+ import {
3
+ Play,
4
+ ChevronDown,
5
+ ChevronRight,
6
+ CheckCircle2,
7
+ XCircle,
8
+ FlaskConical,
9
+ } from "lucide-react";
10
+ import { Button } from "./Button";
11
+ import { Badge } from "./Badge";
12
+ import { Input } from "./Input";
13
+ import { Label } from "./Label";
14
+ import { CodeEditor } from "./CodeEditor";
15
+ import { cn } from "../utils";
16
+ import { usePerformance } from "./PerformanceProvider";
17
+ import {
18
+ type ScriptTestPanelResult,
19
+ buildSecretOverrides,
20
+ distinctSecretNames,
21
+ formatReturnValue,
22
+ hasNoOutput,
23
+ isFailedResult,
24
+ rejectionResult,
25
+ validateSampleContextJson,
26
+ } from "./ScriptTestPanel.logic";
27
+
28
+ /**
29
+ * Result of a single in-UI script test run. Plugin-agnostic shape mirroring
30
+ * the backend `testScript` / `testCollectorScript` output so the panel can
31
+ * be reused by any script field (automation actions, healthcheck collectors,
32
+ * future surfaces). Re-exported from the pure-logic module so the public
33
+ * type stays at this path.
34
+ */
35
+ export type { ScriptTestPanelResult } from "./ScriptTestPanel.logic";
36
+
37
+ /** Arguments handed to {@link ScriptTestPanelProps.onRun} for a single run. */
38
+ export interface ScriptTestRunArgs {
39
+ /**
40
+ * User-supplied per-secret-NAME override VALUES for this run (keyed by the
41
+ * secret name, never the env var), collected from the optional override
42
+ * inputs. Omitted when nothing was overridden. These are masked out of the
43
+ * result server-side and are NEVER the user's real secret — only an
44
+ * explicit test value the operator typed.
45
+ */
46
+ secretOverrides?: Record<string, string>;
47
+ }
48
+
49
+ export interface ScriptTestPanelProps {
50
+ /**
51
+ * Runs the test. The owning feature page supplies this; it typically
52
+ * calls the plugin's `testScript` RPC with the current script + sample
53
+ * context (plus any {@link ScriptTestRunArgs.secretOverrides}) and resolves
54
+ * with the result. Rejecting surfaces as an error.
55
+ */
56
+ onRun: (args: ScriptTestRunArgs) => Promise<ScriptTestPanelResult>;
57
+ /** Disables the Run button (e.g. while the script field is empty). */
58
+ disabled?: boolean;
59
+ /**
60
+ * The script's declared secret → env mapping (`x-secret-env` sibling
61
+ * field). When non-empty, the panel renders an optional per-secret test
62
+ * override input so an operator can supply a realistic value for a run.
63
+ * Real secrets are NEVER resolved in the test path: with no override each
64
+ * secret is injected as a `__SECRET_<NAME>__` placeholder; an override is
65
+ * masked out of the captured output. Omit it and no override UI shows.
66
+ */
67
+ secretEnv?: Record<string, string>;
68
+ /**
69
+ * Editable sample-context slot. Render a {@link ContextSampleEditor} (or
70
+ * any control) here; the panel just lays it out above the results.
71
+ */
72
+ contextEditor?: React.ReactNode;
73
+ /**
74
+ * Note shown under the Run button. Defaults to the central-execution
75
+ * caveat. Pass `null` to hide it.
76
+ */
77
+ note?: React.ReactNode;
78
+ /**
79
+ * Whether the panel is expanded on first render. Defaults to `false`
80
+ * so a compact "Test script" affordance shows under every testable
81
+ * field and the sample-context editor + results only mount on demand.
82
+ * A successful/failed run auto-expands regardless of this.
83
+ */
84
+ defaultOpen?: boolean;
85
+ className?: string;
86
+ }
87
+
88
+ const DEFAULT_NOTE =
89
+ "Runs on the central backend. Real satellite runs may differ.";
90
+
91
+ /**
92
+ * Inline script test panel: a Run button plus collapsible results
93
+ * (return value / stdout / stderr / exit code / duration / error).
94
+ *
95
+ * Purely presentational - it owns no RPC. The owning page wires `onRun`
96
+ * to the appropriate `testScript` mutation. Appears beneath any
97
+ * `x-script-testable` field via `MultiTypeEditorField`.
98
+ */
99
+ export const ScriptTestPanel: React.FC<ScriptTestPanelProps> = ({
100
+ onRun,
101
+ disabled,
102
+ secretEnv,
103
+ contextEditor,
104
+ note = DEFAULT_NOTE,
105
+ defaultOpen = false,
106
+ className,
107
+ }) => {
108
+ const { isLowPower } = usePerformance();
109
+ const [running, setRunning] = React.useState(false);
110
+ // Whole-panel disclosure: collapsed by default so a testable field shows
111
+ // only a compact "Test script" affordance and the sample-context editor +
112
+ // results mount on demand. A run forces it open.
113
+ const [panelOpen, setPanelOpen] = React.useState(defaultOpen);
114
+ const [resultExpanded, setResultExpanded] = React.useState(true);
115
+ const [result, setResult] = React.useState<ScriptTestPanelResult | null>(null);
116
+ // Draft override values keyed by secret NAME. Kept client-side until an
117
+ // explicit run; an empty draft means "use the placeholder".
118
+ const [overrideDrafts, setOverrideDrafts] = React.useState<
119
+ Record<string, string>
120
+ >({});
121
+
122
+ const secretNames = React.useMemo(
123
+ () => distinctSecretNames(secretEnv),
124
+ [secretEnv],
125
+ );
126
+
127
+ const handleRun = React.useCallback(async () => {
128
+ setRunning(true);
129
+ try {
130
+ const res = await onRun({
131
+ secretOverrides: buildSecretOverrides({
132
+ secretEnv,
133
+ drafts: overrideDrafts,
134
+ }),
135
+ });
136
+ setResult(res);
137
+ setResultExpanded(true);
138
+ } catch (error) {
139
+ setResult(rejectionResult(error));
140
+ setResultExpanded(true);
141
+ } finally {
142
+ setRunning(false);
143
+ }
144
+ }, [onRun, secretEnv, overrideDrafts]);
145
+
146
+ const failed = result !== null && isFailedResult(result);
147
+
148
+ return (
149
+ <div
150
+ className={cn(
151
+ "rounded-lg border border-border/60 bg-muted/20",
152
+ className,
153
+ )}
154
+ >
155
+ <button
156
+ type="button"
157
+ onClick={() => setPanelOpen((prev) => !prev)}
158
+ className="flex w-full items-center justify-between gap-3 px-3 py-2 text-left"
159
+ aria-expanded={panelOpen}
160
+ >
161
+ <span className="flex items-center gap-2 text-[11px] font-medium uppercase tracking-wide text-muted-foreground">
162
+ <FlaskConical className="h-3.5 w-3.5" />
163
+ Test script
164
+ {/* When collapsed, surface the last run's outcome as a hint. */}
165
+ {!panelOpen && result !== null && (
166
+ <Badge
167
+ variant={failed ? "destructive" : "secondary"}
168
+ className="font-normal normal-case"
169
+ >
170
+ {failed ? "Failed" : "Success"}
171
+ </Badge>
172
+ )}
173
+ </span>
174
+ {panelOpen ? (
175
+ <ChevronDown className="h-4 w-4 shrink-0 text-muted-foreground" />
176
+ ) : (
177
+ <ChevronRight className="h-4 w-4 shrink-0 text-muted-foreground" />
178
+ )}
179
+ </button>
180
+
181
+ {panelOpen && (
182
+ <div className="space-y-3 border-t border-border/60 p-3">
183
+ <div className="flex items-center justify-end">
184
+ <Button
185
+ type="button"
186
+ size="sm"
187
+ variant="secondary"
188
+ onClick={handleRun}
189
+ disabled={disabled || running}
190
+ >
191
+ <Play
192
+ className={cn(
193
+ "h-3.5 w-3.5",
194
+ running && !isLowPower && "animate-pulse",
195
+ )}
196
+ />
197
+ {running ? "Running…" : "Run"}
198
+ </Button>
199
+ </div>
200
+
201
+ {contextEditor}
202
+
203
+ {secretNames.length > 0 && (
204
+ <div className="space-y-2 rounded-md border border-border/60 bg-card p-3">
205
+ <div className="space-y-0.5">
206
+ <span className="text-[11px] font-medium uppercase tracking-wide text-muted-foreground">
207
+ Secret test overrides
208
+ </span>
209
+ <p className="text-xs text-muted-foreground">
210
+ Optional. Left empty, each secret is injected as a{" "}
211
+ <code className="font-mono">__SECRET_NAME__</code> placeholder.
212
+ Any value you type is a test override only — masked from the
213
+ output and never your real secret.
214
+ </p>
215
+ </div>
216
+ {secretNames.map((name) => (
217
+ <div key={name} className="space-y-1">
218
+ <Label
219
+ htmlFor={`secret-override-${name}`}
220
+ className="font-mono text-xs"
221
+ >
222
+ {name}
223
+ </Label>
224
+ <Input
225
+ id={`secret-override-${name}`}
226
+ type="password"
227
+ value={overrideDrafts[name] ?? ""}
228
+ onChange={(e) =>
229
+ setOverrideDrafts((prev) => ({
230
+ ...prev,
231
+ [name]: e.target.value,
232
+ }))
233
+ }
234
+ placeholder={`__SECRET_${name}__`}
235
+ className="font-mono text-sm"
236
+ />
237
+ </div>
238
+ ))}
239
+ </div>
240
+ )}
241
+
242
+ {note !== null && (
243
+ <p className="text-xs text-muted-foreground">{note}</p>
244
+ )}
245
+
246
+ {result !== null && (
247
+ <div className="rounded-md border border-border bg-card">
248
+ <button
249
+ type="button"
250
+ onClick={() => setResultExpanded((prev) => !prev)}
251
+ className="flex w-full items-center justify-between gap-2 px-3 py-2 text-left"
252
+ >
253
+ <span className="flex items-center gap-2 text-sm font-medium">
254
+ {failed ? (
255
+ <XCircle className="h-4 w-4 text-destructive" />
256
+ ) : (
257
+ <CheckCircle2 className="h-4 w-4 text-emerald-500" />
258
+ )}
259
+ {failed ? "Failed" : "Success"}
260
+ <Badge variant="secondary" className="font-normal">
261
+ {result.durationMs}ms
262
+ </Badge>
263
+ {result.exitCode !== undefined && (
264
+ <Badge variant="secondary" className="font-normal">
265
+ exit {result.exitCode}
266
+ </Badge>
267
+ )}
268
+ </span>
269
+ {resultExpanded ? (
270
+ <ChevronDown className="h-4 w-4 text-muted-foreground" />
271
+ ) : (
272
+ <ChevronRight className="h-4 w-4 text-muted-foreground" />
273
+ )}
274
+ </button>
275
+
276
+ {resultExpanded && (
277
+ <div className="space-y-3 border-t border-border px-3 py-3">
278
+ {result.error !== undefined && (
279
+ <ResultBlock
280
+ label="Error"
281
+ tone="error"
282
+ value={result.error}
283
+ />
284
+ )}
285
+ {result.result !== undefined && (
286
+ <ResultBlock
287
+ label="Return value"
288
+ value={formatReturnValue(result.result)}
289
+ />
290
+ )}
291
+ {result.stdout.length > 0 && (
292
+ <ResultBlock label="stdout" value={result.stdout} />
293
+ )}
294
+ {result.stderr.length > 0 && (
295
+ <ResultBlock
296
+ label="stderr"
297
+ tone="error"
298
+ value={result.stderr}
299
+ />
300
+ )}
301
+ {hasNoOutput(result) && (
302
+ <p className="text-xs italic text-muted-foreground">
303
+ No output.
304
+ </p>
305
+ )}
306
+ </div>
307
+ )}
308
+ </div>
309
+ )}
310
+ </div>
311
+ )}
312
+ </div>
313
+ );
314
+ };
315
+
316
+ const ResultBlock: React.FC<{
317
+ label: string;
318
+ value: string;
319
+ tone?: "error";
320
+ }> = ({ label, value, tone }) => (
321
+ <div className="space-y-1">
322
+ <span className="text-[11px] font-medium uppercase tracking-wide text-muted-foreground">
323
+ {label}
324
+ </span>
325
+ <pre
326
+ className={cn(
327
+ "max-h-48 overflow-auto rounded-md bg-muted/40 p-2 font-mono text-xs whitespace-pre-wrap break-words",
328
+ tone === "error" && "text-destructive",
329
+ )}
330
+ >
331
+ {value}
332
+ </pre>
333
+ </div>
334
+ );
335
+
336
+ // ─── Context sample editor ──────────────────────────────────────────────────
337
+
338
+ export interface ContextSampleEditorProps {
339
+ /** Current sample-context JSON string. */
340
+ value: string;
341
+ onChange: (value: string) => void;
342
+ /** Label above the editor. Defaults to "Sample context". */
343
+ label?: string;
344
+ disabled?: boolean;
345
+ /**
346
+ * Optional control rendered on the label row, e.g. a "Load from run"
347
+ * dropdown. The owning page supplies it (it owns the replay RPC); the
348
+ * editor stays plugin-agnostic and just lays it out.
349
+ */
350
+ runPicker?: React.ReactNode;
351
+ }
352
+
353
+ /**
354
+ * Editable JSON sample-context editor for the test panel. Auto-seeding
355
+ * (from the field's schema / IntelliSense context) is the owning page's
356
+ * job - this component just renders + validates the JSON the user edits.
357
+ *
358
+ * Surfaces a parse error inline so an operator can fix malformed JSON
359
+ * before running. The optional `runPicker` slot lets a page add a "Load
360
+ * from run" dropdown that overwrites the sample with a real run's scope.
361
+ */
362
+ export const ContextSampleEditor: React.FC<ContextSampleEditorProps> = ({
363
+ value,
364
+ onChange,
365
+ label = "Sample context",
366
+ disabled,
367
+ runPicker,
368
+ }) => {
369
+ const parseError = React.useMemo(
370
+ () => validateSampleContextJson(value),
371
+ [value],
372
+ );
373
+
374
+ return (
375
+ <div className="space-y-1.5">
376
+ <div className="flex items-center justify-between gap-3">
377
+ <span className="text-[11px] font-medium uppercase tracking-wide text-muted-foreground">
378
+ {label}
379
+ </span>
380
+ {runPicker}
381
+ </div>
382
+ <CodeEditor
383
+ value={value}
384
+ onChange={onChange}
385
+ language="json"
386
+ minHeight="120px"
387
+ readOnly={disabled}
388
+ />
389
+ {parseError !== null && (
390
+ <p className="text-xs text-destructive">Invalid JSON: {parseError}</p>
391
+ )}
392
+ </div>
393
+ );
394
+ };
@@ -4,6 +4,7 @@ import { cva, type VariantProps } from "class-variance-authority";
4
4
  import { X } from "lucide-react";
5
5
  import { cn } from "../utils";
6
6
  import { usePerformance } from "./PerformanceProvider";
7
+ import { PortalContainerContext } from "./portalContainer";
7
8
 
8
9
  const Sheet = DialogPrimitive.Root;
9
10
  const SheetTrigger = DialogPrimitive.Trigger;
@@ -56,11 +57,23 @@ const SheetContent = React.forwardRef<
56
57
  SheetContentProps
57
58
  >(({ className, children, size, ...props }, ref) => {
58
59
  const { isLowPower } = usePerformance();
60
+ // Expose the content element so popovers/comboboxes rendered inside the
61
+ // sheet portal INTO it — otherwise the modal scroll-lock blocks their
62
+ // internal scrolling (a popover portaled to body is outside the dialog).
63
+ const [content, setContent] = React.useState<HTMLDivElement | null>(null);
64
+ const setRefs = React.useCallback(
65
+ (node: HTMLDivElement | null) => {
66
+ setContent(node);
67
+ if (typeof ref === "function") ref(node);
68
+ else if (ref) ref.current = node;
69
+ },
70
+ [ref],
71
+ );
59
72
  return (
60
73
  <SheetPortal>
61
74
  <SheetOverlay />
62
75
  <DialogPrimitive.Content
63
- ref={ref}
76
+ ref={setRefs}
64
77
  className={cn(
65
78
  sheetContentVariants({ size }),
66
79
  "inset-y-0 right-0 h-full",
@@ -70,11 +83,13 @@ const SheetContent = React.forwardRef<
70
83
  )}
71
84
  {...props}
72
85
  >
73
- {children}
74
- <DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2">
75
- <X className="h-4 w-4" />
76
- <span className="sr-only">Close</span>
77
- </DialogPrimitive.Close>
86
+ <PortalContainerContext.Provider value={content}>
87
+ {children}
88
+ <DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2">
89
+ <X className="h-4 w-4" />
90
+ <span className="sr-only">Close</span>
91
+ </DialogPrimitive.Close>
92
+ </PortalContainerContext.Provider>
78
93
  </DialogPrimitive.Content>
79
94
  </SheetPortal>
80
95
  );
@@ -0,0 +1,104 @@
1
+ import React from "react";
2
+ import { CodeEditor, type TemplateProperty } from "./CodeEditor";
3
+ import { TemplateValueInput } from "./TemplateValueInput";
4
+
5
+ /**
6
+ * Edit mode for a `TemplateInput`. Drives both the rendered widget and
7
+ * the language passed to the underlying Monaco editor in code modes.
8
+ *
9
+ * - `text` — single-line `<Input>` with `{{` autocomplete
10
+ * (`TemplateValueInput`). Cheapest, no Monaco bundle.
11
+ * - `code` — full Monaco TypeScript editor. Use for inline scripts.
12
+ * - `bash` — Monaco shell editor with optional `shellEnvVars`.
13
+ * - `json` / `yaml` — Monaco editor in the matching language. Use for
14
+ * structured config bodies that may interpolate `{{ }}`.
15
+ */
16
+ export type TemplateInputMode = "text" | "code" | "bash" | "json" | "yaml";
17
+
18
+ export interface TemplateInputProps {
19
+ id?: string;
20
+ value: string;
21
+ onChange: (value: string) => void;
22
+ mode?: TemplateInputMode;
23
+ placeholder?: string;
24
+ /**
25
+ * Template properties surfaced after the user types `{{`. Required for
26
+ * the picker to do anything; omit to disable autocomplete entirely.
27
+ */
28
+ templateProperties?: TemplateProperty[];
29
+ /** Optional TS declarations injected into Monaco (code mode only). */
30
+ typeDefinitions?: string;
31
+ /** Optional shell env-var hints (bash mode only). */
32
+ shellEnvVars?: Array<{ name: string; description?: string; example?: string }>;
33
+ /** Min height for code-mode editors. */
34
+ minHeight?: string;
35
+ /** Forwarded to the input/editor. */
36
+ disabled?: boolean;
37
+ className?: string;
38
+ }
39
+
40
+ /**
41
+ * High-level template editor. Picks the right underlying widget for the
42
+ * given `mode` and forwards the standard template-property + type-decl
43
+ * props through to it.
44
+ *
45
+ * - `mode === "text"` (the default) renders a single-line
46
+ * `TemplateValueInput` — the same picker the key/value editor uses.
47
+ * - Any code mode delegates to `CodeEditor`, which already runs the
48
+ * same `{{` autocomplete inside Monaco plus the shell `$` env-var
49
+ * completion provider.
50
+ *
51
+ * Wrapping both in one component lets the automation editor render a
52
+ * single field that switches editor flavour as the user changes the
53
+ * action's `x-editor-types` annotation, without each action having to
54
+ * choose its own widget.
55
+ */
56
+ export const TemplateInput: React.FC<TemplateInputProps> = ({
57
+ id,
58
+ value,
59
+ onChange,
60
+ mode = "text",
61
+ placeholder,
62
+ templateProperties,
63
+ typeDefinitions,
64
+ shellEnvVars,
65
+ minHeight,
66
+ disabled,
67
+ className,
68
+ }) => {
69
+ if (mode === "text") {
70
+ return (
71
+ <TemplateValueInput
72
+ id={id}
73
+ value={value}
74
+ onChange={onChange}
75
+ placeholder={placeholder}
76
+ templateProperties={templateProperties}
77
+ disabled={disabled}
78
+ className={className}
79
+ />
80
+ );
81
+ }
82
+
83
+ const language =
84
+ mode === "code"
85
+ ? "typescript"
86
+ : mode === "bash"
87
+ ? "shell"
88
+ : mode;
89
+
90
+ return (
91
+ <CodeEditor
92
+ id={id}
93
+ value={value}
94
+ onChange={onChange}
95
+ language={language}
96
+ templateProperties={templateProperties}
97
+ typeDefinitions={typeDefinitions}
98
+ shellEnvVars={shellEnvVars}
99
+ minHeight={minHeight}
100
+ placeholder={placeholder}
101
+ readOnly={disabled}
102
+ />
103
+ );
104
+ };
@@ -0,0 +1,111 @@
1
+ import React from "react";
2
+ import { Code2, X } from "lucide-react";
3
+ import { TemplateValueInput } from "./TemplateValueInput";
4
+ import type { TemplateProperty } from "./CodeEditor";
5
+
6
+ export interface TemplateInputToggleProps {
7
+ /**
8
+ * The typed editor to render when the user has NOT switched to
9
+ * template mode — typically a `<Input type="number">`, `<Select>`,
10
+ * `<DateTimePicker>`, etc.
11
+ *
12
+ * Render-prop instead of a `ReactNode` so the toggle can wire focus /
13
+ * disabled state through without imposing a shape on the caller.
14
+ */
15
+ renderTyped: (props: { disabled: boolean }) => React.ReactNode;
16
+ /** Current value (always a string — typed editors serialise to one). */
17
+ value: string;
18
+ /** Called whenever the value changes, regardless of mode. */
19
+ onChange: (value: string) => void;
20
+ /**
21
+ * Whether the toggle starts in template mode. The toggle is
22
+ * uncontrolled by default; pass `templateMode` + `onTemplateModeChange`
23
+ * to control it externally.
24
+ */
25
+ defaultTemplateMode?: boolean;
26
+ templateMode?: boolean;
27
+ onTemplateModeChange?: (templateMode: boolean) => void;
28
+ /** Template properties for the picker (template mode only). */
29
+ templateProperties?: TemplateProperty[];
30
+ /** Placeholder shown in template mode. */
31
+ templatePlaceholder?: string;
32
+ disabled?: boolean;
33
+ className?: string;
34
+ }
35
+
36
+ /**
37
+ * Wraps a typed input with a small "fx" button that flips the field
38
+ * into template-input mode. Used in the automation editor wherever an
39
+ * action config field accepts either a typed literal _or_ a template
40
+ * — number-of-seconds, dropdown choices, dates — so the operator can
41
+ * say "this is dynamic at runtime" without changing the schema.
42
+ *
43
+ * Behaviour:
44
+ *
45
+ * - Default mode renders `renderTyped()`.
46
+ * - Clicking the "fx" pill switches to a `TemplateValueInput` and
47
+ * focuses it. The pill turns into an "X" button.
48
+ * - Clicking the "X" switches back. The value is preserved across
49
+ * toggles — the operator only loses it if they explicitly clear
50
+ * the field.
51
+ *
52
+ * If the current value starts with `{{`, the toggle infers template
53
+ * mode automatically when uncontrolled. This handles round-tripping
54
+ * a previously-saved automation that interpolated this field.
55
+ */
56
+ export const TemplateInputToggle: React.FC<TemplateInputToggleProps> = ({
57
+ renderTyped,
58
+ value,
59
+ onChange,
60
+ defaultTemplateMode,
61
+ templateMode,
62
+ onTemplateModeChange,
63
+ templateProperties,
64
+ templatePlaceholder,
65
+ disabled,
66
+ className,
67
+ }) => {
68
+ const inferredTemplate = value.trim().startsWith("{{");
69
+ const [internalTemplateMode, setInternalTemplateMode] = React.useState(
70
+ defaultTemplateMode ?? inferredTemplate,
71
+ );
72
+ const isTemplate = templateMode ?? internalTemplateMode;
73
+ const setIsTemplate = (next: boolean) => {
74
+ if (templateMode === undefined) setInternalTemplateMode(next);
75
+ onTemplateModeChange?.(next);
76
+ };
77
+
78
+ return (
79
+ <div className={`flex items-center gap-1 ${className ?? ""}`.trim()}>
80
+ <div className="flex-1">
81
+ {isTemplate ? (
82
+ <TemplateValueInput
83
+ value={value}
84
+ onChange={onChange}
85
+ placeholder={templatePlaceholder ?? "{{ trigger.payload.value }}"}
86
+ templateProperties={templateProperties}
87
+ disabled={disabled}
88
+ autoFocus
89
+ />
90
+ ) : (
91
+ renderTyped({ disabled: Boolean(disabled) })
92
+ )}
93
+ </div>
94
+ <button
95
+ type="button"
96
+ disabled={disabled}
97
+ onClick={() => setIsTemplate(!isTemplate)}
98
+ className="inline-flex h-8 w-8 shrink-0 items-center justify-center rounded border border-border bg-card text-xs font-mono text-muted-foreground hover:bg-accent hover:text-accent-foreground disabled:opacity-50"
99
+ aria-label={isTemplate ? "Switch back to typed input" : "Switch to template"}
100
+ title={isTemplate ? "Switch back to typed input" : "Switch to template"}
101
+ >
102
+ {isTemplate ? <X className="h-3 w-3" /> : (
103
+ <span className="flex items-center gap-0.5">
104
+ <Code2 className="h-3 w-3" />
105
+ <span>fx</span>
106
+ </span>
107
+ )}
108
+ </button>
109
+ </div>
110
+ );
111
+ };