@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,26 @@
1
+ // Template-aware JSON validation. See templateValidation.ts for the approach.
2
+ // The real VS Code JSON service still provides highlighting + completion; only
3
+ // its built-in (raw-text) validation is turned off in favour of this, so `{{ }}`
4
+ // is tolerated in any position (including unquoted, e.g. a numeric value).
5
+ import { parse, printParseErrorCode, type ParseError } from "jsonc-parser";
6
+ import {
7
+ substituteTemplates,
8
+ type TemplateDiagnostic,
9
+ } from "./templateValidation";
10
+
11
+ /** Validate template-bearing JSON. Empty content is allowed (no diagnostics). */
12
+ export const validateJsonTemplate = (
13
+ text: string,
14
+ ): TemplateDiagnostic[] => {
15
+ const errors: ParseError[] = [];
16
+ parse(substituteTemplates(text), errors, {
17
+ allowEmptyContent: true,
18
+ allowTrailingComma: false,
19
+ disallowComments: true,
20
+ });
21
+ return errors.map((parseError) => ({
22
+ offset: parseError.offset,
23
+ length: Math.max(parseError.length, 1),
24
+ message: `JSON: ${printParseErrorCode(parseError.error)}`,
25
+ }));
26
+ };
@@ -0,0 +1,132 @@
1
+ // Headless TypeScript / JavaScript script validator.
2
+ //
3
+ // Type-checks user scripts against their generated `context` types WITHOUT a
4
+ // mounted editor, so an automation's collapsed-card scripts (or any script the
5
+ // user isn't currently looking at) still surface type errors. It drives the
6
+ // SAME standalone TS worker the editor uses, via off-screen models.
7
+ //
8
+ // Browser-only (imports `monaco` + the worker accessor). The pure mapping /
9
+ // offset logic lives in `scriptDiagnostics.ts` and is unit-tested there; this
10
+ // module is the thin async glue and is intentionally not unit-tested (no DOM /
11
+ // worker under bun).
12
+ //
13
+ // Why prepend instead of an extra-lib: a global `context` extra-lib would
14
+ // collide with any open editor's own `declare const context` (extra-libs are
15
+ // global to the shared service). Prepending the type defs onto each validated
16
+ // source keeps `context` scoped to that one off-screen file. See
17
+ // `buildValidationSource`.
18
+ import * as monaco from "@codingame/monaco-vscode-editor-api";
19
+ import {
20
+ getJavaScriptWorker,
21
+ getTypeScriptWorker,
22
+ } from "@codingame/monaco-vscode-standalone-typescript-language-features";
23
+ import {
24
+ areVscodeServicesReady,
25
+ ensureStandaloneStdlib,
26
+ } from "./monacoTsService";
27
+ import {
28
+ buildValidationSource,
29
+ mapWorkerDiagnostics,
30
+ type RawTsDiagnostic,
31
+ type ScriptDiagnostic,
32
+ } from "./scriptDiagnostics";
33
+
34
+ export type { ScriptDiagnostic } from "./scriptDiagnostics";
35
+
36
+ export interface ScriptValidationInput {
37
+ /** Caller-chosen identity; the returned map is keyed by this. */
38
+ id: string;
39
+ /** The user's script source (without any generated type prefix). */
40
+ source: string;
41
+ /** Generated `context.d.ts` (+ ambient augmentations) for this script. */
42
+ typeDefinitions: string;
43
+ language: "typescript" | "javascript";
44
+ }
45
+
46
+ // Monotonic across overlapping validation passes so two passes never reuse the
47
+ // same off-screen model URI (which would race on create/dispose).
48
+ let runCounter = 0;
49
+
50
+ /**
51
+ * Validate each script against its `typeDefinitions`. Returns a map keyed by
52
+ * input `id` to that script's diagnostics (empty array = clean). Never throws:
53
+ * any worker/setup failure resolves to no diagnostics so validation can never
54
+ * break the editor it augments.
55
+ *
56
+ * Runs serially. The inline-prepend strategy removes the shared-state
57
+ * constraint that would otherwise force serialization, but validation is not
58
+ * latency-critical and serial keeps worker load predictable.
59
+ */
60
+ export async function validateTypeScriptSources({
61
+ sources,
62
+ }: {
63
+ sources: ScriptValidationInput[];
64
+ }): Promise<Map<string, ScriptDiagnostic[]>> {
65
+ const results = new Map<string, ScriptDiagnostic[]>();
66
+ if (sources.length === 0) {
67
+ return results;
68
+ }
69
+ // CRITICAL: only proceed once an editor has initialized the monaco-vscode
70
+ // services. Initializing them here would collide with the editor wrapper's
71
+ // one-time init ("Services are already initialized") and break the editor.
72
+ if (!areVscodeServicesReady()) {
73
+ return results;
74
+ }
75
+ try {
76
+ await ensureStandaloneStdlib();
77
+ } catch {
78
+ return results;
79
+ }
80
+
81
+ for (const input of sources) {
82
+ try {
83
+ results.set(input.id, await validateOne(input));
84
+ } catch {
85
+ results.set(input.id, []);
86
+ }
87
+ }
88
+ return results;
89
+ }
90
+
91
+ async function validateOne(
92
+ input: ScriptValidationInput,
93
+ ): Promise<ScriptDiagnostic[]> {
94
+ const { text, prependedLineCount } = buildValidationSource({
95
+ typeDefinitions: input.typeDefinitions,
96
+ source: input.source,
97
+ });
98
+ const ext = input.language === "javascript" ? "js" : "ts";
99
+ const uri = monaco.Uri.parse(
100
+ `file:///script-validation/${runCounter++}.${ext}`,
101
+ );
102
+ monaco.editor.getModel(uri)?.dispose();
103
+ const model = monaco.editor.createModel(text, input.language, uri);
104
+ try {
105
+ const getWorker =
106
+ input.language === "javascript"
107
+ ? await getJavaScriptWorker()
108
+ : await getTypeScriptWorker();
109
+ const client = await getWorker(uri);
110
+ const fileName = uri.toString();
111
+ const [syntactic, semantic] = await Promise.all([
112
+ client.getSyntacticDiagnostics(fileName),
113
+ client.getSemanticDiagnostics(fileName),
114
+ ]);
115
+ const diagnostics: RawTsDiagnostic[] = [...syntactic, ...semantic].map(
116
+ (diagnostic) => ({
117
+ start: diagnostic.start,
118
+ length: diagnostic.length,
119
+ messageText: diagnostic.messageText,
120
+ category: diagnostic.category,
121
+ code: diagnostic.code,
122
+ }),
123
+ );
124
+ return mapWorkerDiagnostics({
125
+ diagnostics,
126
+ validationText: text,
127
+ prependedLineCount,
128
+ });
129
+ } finally {
130
+ model.dispose();
131
+ }
132
+ }
@@ -0,0 +1,34 @@
1
+ import { describe, it, expect } from "bun:test";
2
+ import { validateXmlTemplate } from "./validateXmlTemplate";
3
+
4
+ describe("validateXmlTemplate", () => {
5
+ it("accepts valid XML", () => {
6
+ expect(validateXmlTemplate("<root><a>1</a></root>")).toEqual([]);
7
+ });
8
+
9
+ it("accepts empty content", () => {
10
+ expect(validateXmlTemplate("")).toEqual([]);
11
+ expect(validateXmlTemplate(" \n ")).toEqual([]);
12
+ });
13
+
14
+ it("accepts a template in element text", () => {
15
+ expect(
16
+ validateXmlTemplate("<root><repo>{{trigger.payload.repository}}</repo></root>"),
17
+ ).toEqual([]);
18
+ });
19
+
20
+ it("accepts a template in a (quoted) attribute value", () => {
21
+ expect(validateXmlTemplate('<root attr="{{id}}"></root>')).toEqual([]);
22
+ });
23
+
24
+ it("flags a mismatched closing tag", () => {
25
+ const diagnostics = validateXmlTemplate("<a><b></a>");
26
+ expect(diagnostics.length).toBeGreaterThan(0);
27
+ expect(diagnostics[0]?.message).toContain("XML:");
28
+ });
29
+
30
+ it("flags an unclosed tag even with templates present", () => {
31
+ const diagnostics = validateXmlTemplate("<root><x>{{v}}</root>");
32
+ expect(diagnostics.length).toBeGreaterThan(0);
33
+ });
34
+ });
@@ -0,0 +1,35 @@
1
+ // Template-aware XML validation. See templateValidation.ts for the approach.
2
+ // There is no @codingame standalone XML language service (only Monarch
3
+ // highlighting), so we use fast-xml-parser's validator after template
4
+ // substitution. The validator reports the FIRST error only, with 1-based
5
+ // line/column, which we convert to an offset/range in the original text.
6
+ import { XMLValidator } from "fast-xml-parser";
7
+ import {
8
+ lineColumnToOffset,
9
+ substituteTemplates,
10
+ type TemplateDiagnostic,
11
+ } from "./templateValidation";
12
+
13
+ /** Validate template-bearing XML. Empty content is allowed (no diagnostics). */
14
+ export const validateXmlTemplate = (
15
+ text: string,
16
+ ): TemplateDiagnostic[] => {
17
+ const substituted = substituteTemplates(text);
18
+ if (substituted.trim() === "") {
19
+ return [];
20
+ }
21
+ const result = XMLValidator.validate(substituted, {
22
+ allowBooleanAttributes: true,
23
+ });
24
+ if (result === true) {
25
+ return [];
26
+ }
27
+ const { msg, line, col } = result.err;
28
+ return [
29
+ {
30
+ offset: lineColumnToOffset({ text: substituted, line, column: col }),
31
+ length: 1,
32
+ message: `XML: ${msg}`,
33
+ },
34
+ ];
35
+ };
@@ -0,0 +1,39 @@
1
+ import { describe, it, expect } from "bun:test";
2
+ import { validateYamlTemplate } from "./validateYamlTemplate";
3
+
4
+ describe("validateYamlTemplate", () => {
5
+ it("accepts valid YAML", () => {
6
+ expect(validateYamlTemplate("a: 1\nb: two\n")).toEqual([]);
7
+ });
8
+
9
+ it("accepts empty content", () => {
10
+ expect(validateYamlTemplate("")).toEqual([]);
11
+ });
12
+
13
+ it("accepts a template as a quoted string value", () => {
14
+ expect(validateYamlTemplate('repo: "{{trigger.payload.repository}}"')).toEqual(
15
+ [],
16
+ );
17
+ });
18
+
19
+ it("accepts an UNQUOTED template value (e.g. a number)", () => {
20
+ expect(validateYamlTemplate("timeout: {{trigger.payload.timeout}}")).toEqual(
21
+ [],
22
+ );
23
+ });
24
+
25
+ it("accepts a template as a list item", () => {
26
+ expect(validateYamlTemplate("items:\n - {{count}}\n - 2")).toEqual([]);
27
+ });
28
+
29
+ it("flags tabs used as indentation", () => {
30
+ const diagnostics = validateYamlTemplate("foo:\n\tbar: 1");
31
+ expect(diagnostics.length).toBeGreaterThan(0);
32
+ expect(diagnostics[0]?.message).toContain("YAML:");
33
+ });
34
+
35
+ it("flags a genuine structural error (with templates present)", () => {
36
+ const diagnostics = validateYamlTemplate("a: {{x}}\n b: 2");
37
+ expect(diagnostics.length).toBeGreaterThan(0);
38
+ });
39
+ });
@@ -0,0 +1,28 @@
1
+ // Template-aware YAML validation. See templateValidation.ts for the approach.
2
+ // There is no @codingame standalone YAML language service (only Monarch
3
+ // highlighting), so we parse with the `yaml` package after template
4
+ // substitution. YAML is permissive (it often parses `{{ }}` as a flow
5
+ // collection), but substitution gives accurate structural validation and still
6
+ // catches real mistakes (bad indentation, tabs, duplicate keys, ...).
7
+ import { parseDocument } from "yaml";
8
+ import {
9
+ substituteTemplates,
10
+ type TemplateDiagnostic,
11
+ } from "./templateValidation";
12
+
13
+ /** Validate template-bearing YAML. */
14
+ export const validateYamlTemplate = (
15
+ text: string,
16
+ ): TemplateDiagnostic[] => {
17
+ const doc = parseDocument(substituteTemplates(text), {
18
+ prettyErrors: false,
19
+ });
20
+ return doc.errors.map((error) => {
21
+ const [start, end] = error.pos;
22
+ return {
23
+ offset: start,
24
+ length: Math.max(end - start, 1),
25
+ message: `YAML: ${error.message}`,
26
+ };
27
+ });
28
+ };
@@ -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 Dialog = DialogPrimitive.Root;
9
10
 
@@ -64,12 +65,23 @@ const DialogContent = React.forwardRef<
64
65
  DialogContentProps & DialogContentExtraProps
65
66
  >(({ className, children, size, hideCloseButton, ...props }, ref) => {
66
67
  const { isLowPower } = usePerformance();
68
+ // Expose the content element so popovers/comboboxes inside the dialog portal
69
+ // INTO it, otherwise the modal scroll-lock blocks their internal scrolling.
70
+ const [content, setContent] = React.useState<HTMLDivElement | null>(null);
71
+ const setRefs = React.useCallback(
72
+ (node: HTMLDivElement | null) => {
73
+ setContent(node);
74
+ if (typeof ref === "function") ref(node);
75
+ else if (ref) ref.current = node;
76
+ },
77
+ [ref],
78
+ );
67
79
  return (
68
80
  <DialogPortal>
69
81
  <DialogOverlay />
70
82
  <div className="fixed inset-0 z-50 flex items-center justify-center p-4 pointer-events-none">
71
83
  <DialogPrimitive.Content
72
- ref={ref}
84
+ ref={setRefs}
73
85
  className={cn(
74
86
  "pointer-events-auto relative",
75
87
  dialogContentVariants({ size }),
@@ -79,16 +91,25 @@ const DialogContent = React.forwardRef<
79
91
  )}
80
92
  {...props}
81
93
  >
82
- <div className="-mx-2 px-2 flex flex-col gap-6">{children}</div>
83
- {!hideCloseButton && (
84
- <DialogPrimitive.Close
85
- 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"
86
- aria-label="Close"
87
- >
88
- <X className="h-4 w-4" />
89
- <span className="sr-only">Close</span>
90
- </DialogPrimitive.Close>
91
- )}
94
+ <PortalContainerContext.Provider value={content}>
95
+ {/* `min-h-0 flex-1` lets this wrapper fill the height when a
96
+ consumer makes `DialogContent` a tall flex column (e.g. the
97
+ CodeEditor popout, so a `fillHeight` editor fills the body).
98
+ Inert for the default (non-flex) dialog: `flex-1` only affects
99
+ flex items, and `min-h-0` is the block default. */}
100
+ <div className="-mx-2 px-2 flex min-h-0 flex-1 flex-col gap-6">
101
+ {children}
102
+ </div>
103
+ {!hideCloseButton && (
104
+ <DialogPrimitive.Close
105
+ 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"
106
+ aria-label="Close"
107
+ >
108
+ <X className="h-4 w-4" />
109
+ <span className="sr-only">Close</span>
110
+ </DialogPrimitive.Close>
111
+ )}
112
+ </PortalContainerContext.Provider>
92
113
  </DialogPrimitive.Content>
93
114
  </div>
94
115
  </DialogPortal>
@@ -0,0 +1,121 @@
1
+ import React from "react";
2
+ import { Input } from "./Input";
3
+ import {
4
+ Select,
5
+ SelectContent,
6
+ SelectItem,
7
+ SelectTrigger,
8
+ SelectValue,
9
+ } from "./Select";
10
+
11
+ /**
12
+ * The duration units this input edits. Mirrors the single-unit object
13
+ * form the automation `Duration` schema accepts
14
+ * (`{ seconds } | { minutes } | { hours }`).
15
+ */
16
+ export type DurationUnit = "seconds" | "minutes" | "hours";
17
+
18
+ /**
19
+ * A single-unit duration value: `{ seconds: 30 }`, `{ minutes: 5 }`, etc.
20
+ * This is exactly the object form the backend `Duration` schema accepts,
21
+ * so a `DurationInput` round-trips losslessly to/from the stored value.
22
+ */
23
+ export type DurationValue = Partial<Record<DurationUnit, number>>;
24
+
25
+ export interface DurationInputProps {
26
+ /** Current duration, or undefined when unset. */
27
+ value: DurationValue | undefined;
28
+ onChange: (next: DurationValue | undefined) => void;
29
+ /** Default unit when the operator first types a value. Defaults to "minutes". */
30
+ defaultUnit?: DurationUnit;
31
+ disabled?: boolean;
32
+ id?: string;
33
+ className?: string;
34
+ }
35
+
36
+ const UNIT_LABELS: Record<DurationUnit, string> = {
37
+ seconds: "seconds",
38
+ minutes: "minutes",
39
+ hours: "hours",
40
+ };
41
+
42
+ /** Read the (unit, amount) pair out of a single-unit duration value. */
43
+ function decompose(
44
+ value: DurationValue | undefined,
45
+ fallbackUnit: DurationUnit,
46
+ ): { unit: DurationUnit; amount: number | undefined } {
47
+ if (value) {
48
+ for (const unit of ["seconds", "minutes", "hours"] as const) {
49
+ const amount = value[unit];
50
+ if (amount !== undefined) return { unit, amount };
51
+ }
52
+ }
53
+ return { unit: fallbackUnit, amount: undefined };
54
+ }
55
+
56
+ /**
57
+ * Number + unit picker for a single-unit duration (`for:` dwells,
58
+ * threshold windows, poll intervals). Emits the object form the backend
59
+ * `Duration` schema accepts, so it round-trips losslessly through YAML.
60
+ *
61
+ * No animations or heavy effects, so no `usePerformance` gating is needed
62
+ * - it is a plain numeric input plus a unit `Select`.
63
+ */
64
+ export const DurationInput: React.FC<DurationInputProps> = ({
65
+ value,
66
+ onChange,
67
+ defaultUnit = "minutes",
68
+ disabled,
69
+ id,
70
+ className,
71
+ }) => {
72
+ const { unit, amount } = decompose(value, defaultUnit);
73
+
74
+ const emit = (nextUnit: DurationUnit, nextAmount: number | undefined) => {
75
+ const cleared: DurationValue | undefined = undefined;
76
+ if (nextAmount === undefined || !Number.isFinite(nextAmount)) {
77
+ onChange(cleared);
78
+ return;
79
+ }
80
+ onChange({ [nextUnit]: Math.max(0, Math.floor(nextAmount)) });
81
+ };
82
+
83
+ return (
84
+ <div className={`flex items-center gap-2 ${className ?? ""}`}>
85
+ <Input
86
+ id={id}
87
+ type="number"
88
+ min={1}
89
+ inputMode="numeric"
90
+ className="w-24"
91
+ value={amount ?? ""}
92
+ disabled={disabled}
93
+ placeholder="0"
94
+ onChange={(event) =>
95
+ emit(
96
+ unit,
97
+ event.target.value === ""
98
+ ? undefined
99
+ : Number(event.target.value),
100
+ )
101
+ }
102
+ />
103
+ <Select
104
+ value={unit}
105
+ disabled={disabled}
106
+ onValueChange={(nextUnit) => emit(nextUnit as DurationUnit, amount)}
107
+ >
108
+ <SelectTrigger className="w-32">
109
+ <SelectValue />
110
+ </SelectTrigger>
111
+ <SelectContent>
112
+ {(["seconds", "minutes", "hours"] as const).map((u) => (
113
+ <SelectItem key={u} value={u}>
114
+ {UNIT_LABELS[u]}
115
+ </SelectItem>
116
+ ))}
117
+ </SelectContent>
118
+ </Select>
119
+ </div>
120
+ );
121
+ };
@@ -3,7 +3,12 @@ import React from "react";
3
3
  import { EmptyState } from "../../index";
4
4
 
5
5
  import type { DynamicFormProps } from "./types";
6
- import { extractDefaults, isValueEmpty, isFieldHiddenByCondition } from "./utils";
6
+ import {
7
+ extractDefaults,
8
+ isValueEmpty,
9
+ isFieldHiddenByCondition,
10
+ findSecretEnvSibling,
11
+ } from "./utils";
7
12
  import { FormField } from "./FormField";
8
13
 
9
14
  /**
@@ -18,9 +23,15 @@ export const DynamicForm: React.FC<DynamicFormProps> = ({
18
23
  onValidChange,
19
24
  optionsResolvers,
20
25
  templateProperties,
26
+ templateCompletionProvider,
21
27
  typeDefinitions,
22
28
  shellEnvVars,
23
29
  starterTemplates,
30
+ scriptTestRenderer,
31
+ secretNames,
32
+ acquireTypes,
33
+ acquireResetKey,
34
+ importablePackages,
24
35
  }) => {
25
36
  // Track previous validity to avoid redundant callbacks
26
37
  const prevValidRef = React.useRef<boolean | undefined>(undefined);
@@ -87,6 +98,14 @@ export const DynamicForm: React.FC<DynamicFormProps> = ({
87
98
  );
88
99
  }
89
100
 
101
+ // The script field and its `x-secret-env` field are top-level siblings in
102
+ // an action config, so resolve the mapping once at the root and forward it
103
+ // to every field; a testable script field passes it to the test panel.
104
+ const rootSecretEnv = findSecretEnvSibling({
105
+ properties: schema.properties,
106
+ values: value,
107
+ });
108
+
90
109
  return (
91
110
  <div className="space-y-6">
92
111
  {Object.entries(schema.properties)
@@ -116,9 +135,16 @@ export const DynamicForm: React.FC<DynamicFormProps> = ({
116
135
  formValues={value}
117
136
  optionsResolvers={optionsResolvers}
118
137
  templateProperties={templateProperties}
138
+ templateCompletionProvider={templateCompletionProvider}
119
139
  typeDefinitions={typeDefinitions}
120
140
  shellEnvVars={shellEnvVars}
121
141
  starterTemplates={starterTemplates}
142
+ scriptTestRenderer={scriptTestRenderer}
143
+ secretNames={secretNames}
144
+ acquireTypes={acquireTypes}
145
+ acquireResetKey={acquireResetKey}
146
+ importablePackages={importablePackages}
147
+ siblingSecretEnv={rootSecretEnv}
122
148
  onChange={(val) => onChange({ ...value, [key]: val })}
123
149
  />
124
150
  );