@checkstack/ui 1.11.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 (45) hide show
  1. package/.storybook/main.ts +43 -0
  2. package/CHANGELOG.md +181 -0
  3. package/package.json +4 -4
  4. package/scripts/generate-stdlib-types.ts +23 -0
  5. package/src/components/ActionCard.tsx +96 -8
  6. package/src/components/CodeEditor/CodeEditor.tsx +95 -14
  7. package/src/components/CodeEditor/TypefoxEditor.tsx +279 -123
  8. package/src/components/CodeEditor/generated/builtin-modules.json +1 -0
  9. package/src/components/CodeEditor/importSpecifiers.test.ts +286 -0
  10. package/src/components/CodeEditor/importSpecifiers.ts +267 -0
  11. package/src/components/CodeEditor/index.ts +24 -0
  12. package/src/components/CodeEditor/monacoTsService.ts +217 -0
  13. package/src/components/CodeEditor/popoutTitle.test.ts +37 -0
  14. package/src/components/CodeEditor/popoutTitle.ts +31 -0
  15. package/src/components/CodeEditor/scriptDiagnostics.test.ts +135 -0
  16. package/src/components/CodeEditor/scriptDiagnostics.ts +172 -0
  17. package/src/components/CodeEditor/types.ts +59 -0
  18. package/src/components/CodeEditor/validateScripts.ts +132 -0
  19. package/src/components/Dialog.tsx +32 -11
  20. package/src/components/DurationInput.tsx +121 -0
  21. package/src/components/DynamicForm/DynamicForm.tsx +25 -1
  22. package/src/components/DynamicForm/FormField.tsx +109 -1
  23. package/src/components/DynamicForm/MultiTypeEditorField.tsx +67 -2
  24. package/src/components/DynamicForm/SecretEnvEditor.tsx +315 -0
  25. package/src/components/DynamicForm/index.ts +6 -0
  26. package/src/components/DynamicForm/secretEnv.logic.test.ts +126 -0
  27. package/src/components/DynamicForm/secretEnv.logic.ts +87 -0
  28. package/src/components/DynamicForm/types.ts +72 -1
  29. package/src/components/DynamicForm/utils.ts +32 -0
  30. package/src/components/Popover.tsx +6 -1
  31. package/src/components/ScriptTestPanel.logic.test.ts +139 -0
  32. package/src/components/ScriptTestPanel.logic.ts +137 -0
  33. package/src/components/ScriptTestPanel.tsx +394 -0
  34. package/src/components/Sheet.tsx +21 -6
  35. package/src/components/TimeOfDayInput.tsx +116 -0
  36. package/src/components/comboboxInteraction.ts +39 -0
  37. package/src/components/portalContainer.ts +24 -0
  38. package/src/index.ts +4 -0
  39. package/stories/ActionCard.stories.tsx +60 -0
  40. package/stories/CodeEditor.stories.tsx +47 -2
  41. package/stories/DurationInput.stories.tsx +59 -0
  42. package/stories/ScriptTestPanel.stories.tsx +106 -0
  43. package/stories/SecretEnvEditor.stories.tsx +80 -0
  44. package/stories/TimeOfDayInput.stories.tsx +34 -0
  45. package/tsconfig.json +1 -0
@@ -0,0 +1,315 @@
1
+ import React from "react";
2
+ import { Plus, Trash2, KeyRound, Search, AlertTriangle } from "lucide-react";
3
+ import { Button } from "../Button";
4
+ import { Input } from "../Input";
5
+ import { Popover, PopoverAnchor, PopoverContent } from "../Popover";
6
+ import { usePerformance } from "../PerformanceProvider";
7
+ import {
8
+ comboboxAnchorProps,
9
+ isAnchorInteraction,
10
+ } from "../comboboxInteraction";
11
+ import {
12
+ objectToRows,
13
+ rowsToObject,
14
+ unknownSecretNames,
15
+ type SecretEnvRow,
16
+ } from "./secretEnv.logic";
17
+
18
+ /**
19
+ * Editor for a secret -> env mapping (`{ ENV_NAME: "${{ secrets.NAME }}" }`).
20
+ *
21
+ * Each row is an env-var name (free text) plus the referenced secret name.
22
+ * The secret field is a searchable combobox (modeled on `VariablePicker` /
23
+ * `PackageNameCombobox`): type to filter the `secretNames` supplied by the
24
+ * secrets plugin's `listSecretNames`, pick one, or free-type a name that
25
+ * isn't there yet (it still round-trips). The stored value is always the
26
+ * canonical `${{ secrets.NAME }}` template.
27
+ *
28
+ * When a row references a name that the loaded `secretNames` doesn't contain,
29
+ * the row shows a non-blocking warning (red border + message) — the secret
30
+ * may have been deleted/renamed or will be created later, so save is not
31
+ * prevented. No warning is shown while `secretNames` is still loading
32
+ * (undefined/empty).
33
+ *
34
+ * No infinite animations or blurs, so it degrades fine on low-power devices.
35
+ */
36
+ export interface SecretEnvEditorProps {
37
+ /** Unique id prefix for inputs. */
38
+ id: string;
39
+ /** Current mapping `{ ENV_NAME: "${{ secrets.NAME }}" }`. */
40
+ value: Record<string, string>;
41
+ /** Callback when the mapping changes. */
42
+ onChange: (next: Record<string, string>) => void;
43
+ /** Available secret names for the value picker (names only, never values). */
44
+ secretNames?: string[];
45
+ }
46
+
47
+ export const SecretEnvEditor: React.FC<SecretEnvEditorProps> = ({
48
+ id,
49
+ value,
50
+ onChange,
51
+ secretNames,
52
+ }) => {
53
+ // Internal row state allows incomplete rows while editing; serialization
54
+ // to the parent drops them (mirrors KeyValueEditor).
55
+ const [rows, setRows] = React.useState<SecretEnvRow[]>(() => objectToRows(value));
56
+ const isInternalChangeRef = React.useRef(false);
57
+
58
+ React.useEffect(() => {
59
+ if (isInternalChangeRef.current) {
60
+ isInternalChangeRef.current = false;
61
+ return;
62
+ }
63
+ if (Object.keys(value).length > 0) {
64
+ setRows(objectToRows(value));
65
+ }
66
+ }, [value]);
67
+
68
+ const notify = (next: SecretEnvRow[]) => {
69
+ isInternalChangeRef.current = true;
70
+ setRows(next);
71
+ onChange(rowsToObject(next));
72
+ };
73
+
74
+ // Referenced names not in the loaded list — drives the per-row warning.
75
+ // Empty while loading (undefined/empty list), so we don't warn early.
76
+ const unknown = React.useMemo(
77
+ () => unknownSecretNames({ rows, secretNames }),
78
+ [rows, secretNames],
79
+ );
80
+
81
+ return (
82
+ <div className="space-y-2">
83
+ {rows.length === 0 && (
84
+ <p className="text-xs text-muted-foreground italic">
85
+ No secrets mapped. Add an environment variable backed by a secret.
86
+ </p>
87
+ )}
88
+
89
+ {rows.map((row, index) => {
90
+ const isUnknown =
91
+ row.secretName.trim() !== "" && unknown.has(row.secretName.trim());
92
+ return (
93
+ <div key={index} className="space-y-1">
94
+ <div className="flex items-center gap-2">
95
+ <Input
96
+ id={`${id}-env-${index}`}
97
+ value={row.envName}
98
+ onChange={(e) => {
99
+ const next = [...rows];
100
+ next[index] = { ...next[index], envName: e.target.value };
101
+ notify(next);
102
+ }}
103
+ placeholder="ENV_NAME"
104
+ className="flex-1 font-mono text-sm"
105
+ />
106
+ <span className="text-muted-foreground">=</span>
107
+ <SecretNameCombobox
108
+ id={`${id}-secret-${index}`}
109
+ value={row.secretName}
110
+ secretNames={secretNames}
111
+ invalid={isUnknown}
112
+ onChange={(secretName) => {
113
+ const next = [...rows];
114
+ next[index] = { ...next[index], secretName };
115
+ notify(next);
116
+ }}
117
+ />
118
+ <Button
119
+ type="button"
120
+ variant="ghost"
121
+ size="icon"
122
+ onClick={() => {
123
+ const next = [...rows];
124
+ next.splice(index, 1);
125
+ notify(next);
126
+ }}
127
+ className="h-8 w-8 shrink-0 text-destructive hover:bg-destructive/10 hover:text-destructive/90"
128
+ >
129
+ <Trash2 className="h-4 w-4" />
130
+ </Button>
131
+ </div>
132
+ {isUnknown && (
133
+ <p className="flex items-center gap-1 pl-1 text-xs text-destructive">
134
+ <AlertTriangle className="h-3 w-3 shrink-0" />
135
+ <span>
136
+ No secret named{" "}
137
+ <code className="font-mono">{row.secretName.trim()}</code> — it
138
+ may have been deleted or renamed.
139
+ </span>
140
+ </p>
141
+ )}
142
+ </div>
143
+ );
144
+ })}
145
+
146
+ <Button
147
+ type="button"
148
+ variant="outline"
149
+ size="sm"
150
+ onClick={() => notify([...rows, { envName: "", secretName: "" }])}
151
+ className="h-8 gap-1"
152
+ >
153
+ <Plus className="h-4 w-4" />
154
+ Add secret
155
+ </Button>
156
+ </div>
157
+ );
158
+ };
159
+
160
+ interface SecretNameComboboxProps {
161
+ id: string;
162
+ value: string;
163
+ secretNames?: string[];
164
+ invalid?: boolean;
165
+ onChange: (next: string) => void;
166
+ }
167
+
168
+ /**
169
+ * Searchable secret-name field. Composes `Popover` + `Input` (no Combobox
170
+ * primitive exists) and mirrors `PackageNameCombobox`: a scrollable,
171
+ * type-to-filter list with keyboard navigation. Picking sets the value;
172
+ * a free-typed name not in the list still round-trips (a secret may be
173
+ * created later). `isLowPower`-aware: no entry transition on the list.
174
+ */
175
+ const SecretNameCombobox: React.FC<SecretNameComboboxProps> = ({
176
+ id,
177
+ value,
178
+ secretNames,
179
+ invalid,
180
+ onChange,
181
+ }) => {
182
+ const { isLowPower } = usePerformance();
183
+ const [open, setOpen] = React.useState(false);
184
+ const [activeIndex, setActiveIndex] = React.useState(0);
185
+ const inputRef = React.useRef<HTMLInputElement>(null);
186
+
187
+ const hasNames = (secretNames?.length ?? 0) > 0;
188
+
189
+ const filtered = React.useMemo(() => {
190
+ const names = secretNames ?? [];
191
+ const q = value.trim().toLowerCase();
192
+ if (q === "") return names;
193
+ return names.filter((n) => n.toLowerCase().includes(q));
194
+ }, [secretNames, value]);
195
+
196
+ // Keep the highlighted row in range as the filter changes.
197
+ React.useEffect(() => {
198
+ setActiveIndex(0);
199
+ }, [value]);
200
+
201
+ const pick = (name: string) => {
202
+ onChange(name);
203
+ setOpen(false);
204
+ inputRef.current?.focus();
205
+ };
206
+
207
+ const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
208
+ if (e.key === "Escape") {
209
+ setOpen(false);
210
+ return;
211
+ }
212
+ if (!open || filtered.length === 0) return;
213
+ switch (e.key) {
214
+ case "ArrowDown": {
215
+ e.preventDefault();
216
+ setActiveIndex((prev) => (prev < filtered.length - 1 ? prev + 1 : 0));
217
+ break;
218
+ }
219
+ case "ArrowUp": {
220
+ e.preventDefault();
221
+ setActiveIndex((prev) => (prev > 0 ? prev - 1 : filtered.length - 1));
222
+ break;
223
+ }
224
+ case "Enter": {
225
+ const candidate = filtered[activeIndex];
226
+ if (candidate) {
227
+ e.preventDefault();
228
+ pick(candidate);
229
+ }
230
+ break;
231
+ }
232
+ }
233
+ };
234
+
235
+ return (
236
+ <div className="relative flex-1">
237
+ <Popover open={open && hasNames} onOpenChange={setOpen}>
238
+ <PopoverAnchor asChild>
239
+ <div className="relative">
240
+ <KeyRound className="pointer-events-none absolute left-2 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
241
+ <Input
242
+ ref={inputRef}
243
+ id={id}
244
+ value={value}
245
+ onChange={(e) => {
246
+ onChange(e.target.value);
247
+ if (hasNames) setOpen(true);
248
+ }}
249
+ onFocus={() => {
250
+ if (hasNames) setOpen(true);
251
+ }}
252
+ onKeyDown={handleKeyDown}
253
+ placeholder="secret name"
254
+ autoComplete="off"
255
+ aria-invalid={invalid || undefined}
256
+ className={`pl-8 font-mono text-sm ${
257
+ invalid
258
+ ? "border-destructive focus-visible:ring-destructive"
259
+ : ""
260
+ }`}
261
+ {...comboboxAnchorProps}
262
+ />
263
+ </div>
264
+ </PopoverAnchor>
265
+ <PopoverContent
266
+ align="start"
267
+ className="w-[--radix-popover-trigger-width] p-0"
268
+ // Keep focus in the input so typing continues uninterrupted, and
269
+ // don't yank focus on close (would re-trigger the anchor's onFocus).
270
+ onOpenAutoFocus={(e) => e.preventDefault()}
271
+ onCloseAutoFocus={(e) => e.preventDefault()}
272
+ // The anchor input lives OUTSIDE the floating content, so the very
273
+ // focus/click that opens the list is otherwise seen by Radix's
274
+ // dismissable layer as an outside interaction and closes it on the
275
+ // same frame. Guard both handlers against anchor-origin events.
276
+ onPointerDownOutside={(e) => {
277
+ if (isAnchorInteraction(e.target)) e.preventDefault();
278
+ }}
279
+ onFocusOutside={(e) => {
280
+ if (isAnchorInteraction(e.target)) e.preventDefault();
281
+ }}
282
+ >
283
+ <div className="max-h-60 overflow-y-auto py-1">
284
+ {filtered.length === 0 ? (
285
+ <p className="px-3 py-3 text-center text-xs italic text-muted-foreground">
286
+ No matching secrets.
287
+ </p>
288
+ ) : (
289
+ filtered.map((name, index) => (
290
+ <button
291
+ key={name}
292
+ type="button"
293
+ // mousedown so the pick lands before the input's blur shuffle.
294
+ onMouseDown={(e) => e.preventDefault()}
295
+ onClick={() => pick(name)}
296
+ onMouseEnter={() => setActiveIndex(index)}
297
+ className={`flex w-full items-center gap-2 px-3 py-1.5 text-left ${
298
+ index === activeIndex
299
+ ? "bg-accent text-accent-foreground"
300
+ : ""
301
+ } ${isLowPower ? "" : "transition-colors"} hover:bg-accent hover:text-accent-foreground`}
302
+ >
303
+ <Search className="h-3 w-3 shrink-0 text-muted-foreground" />
304
+ <code className="flex-1 truncate font-mono text-xs">
305
+ {name}
306
+ </code>
307
+ </button>
308
+ ))
309
+ )}
310
+ </div>
311
+ </PopoverContent>
312
+ </Popover>
313
+ </div>
314
+ );
315
+ };
@@ -4,6 +4,10 @@ export { DynamicForm } from "./DynamicForm";
4
4
  // Sub-components for advanced usage
5
5
  export { MultiTypeEditorField } from "./MultiTypeEditorField";
6
6
  export { KeyValueEditor, type KeyValuePair } from "./KeyValueEditor";
7
+ export {
8
+ SecretEnvEditor,
9
+ type SecretEnvEditorProps,
10
+ } from "./SecretEnvEditor";
7
11
 
8
12
  // Types for external consumers
9
13
  export type {
@@ -15,6 +19,7 @@ export type {
15
19
  EditorType,
16
20
  ShellEnvVar,
17
21
  EditorStarterTemplates,
22
+ ScriptTestRenderer,
18
23
  } from "./types";
19
24
 
20
25
  // Utility functions
@@ -23,4 +28,5 @@ export {
23
28
  parseFormData,
24
29
  detectEditorType,
25
30
  EDITOR_TYPE_LABELS,
31
+ findSecretEnvSibling,
26
32
  } from "./utils";
@@ -0,0 +1,126 @@
1
+ import { describe, it, expect } from "bun:test";
2
+ import {
3
+ parseSecretName,
4
+ toSecretTemplate,
5
+ objectToRows,
6
+ rowsToObject,
7
+ unknownSecretNames,
8
+ type SecretEnvRow,
9
+ } from "./secretEnv.logic";
10
+
11
+ describe("parseSecretName", () => {
12
+ it("extracts the name from a ${{ secrets.NAME }} template", () => {
13
+ expect(parseSecretName("${{ secrets.jira_token }}")).toBe("jira_token");
14
+ expect(parseSecretName("${{secrets.x}}")).toBe("x");
15
+ });
16
+
17
+ it("accepts a legacy bare secret name and returns it as-is", () => {
18
+ // Bare names are tolerated on the wire (normalized to a template by the
19
+ // schema), so the picker shows the same name for both forms.
20
+ expect(parseSecretName("plain")).toBe("plain");
21
+ expect(parseSecretName("SECRET")).toBe("SECRET");
22
+ expect(parseSecretName("my-secret")).toBe("my-secret");
23
+ });
24
+
25
+ it("returns empty for junk that is neither a template nor a bare name", () => {
26
+ // Inline interpolation is not a whole-value secret reference.
27
+ expect(parseSecretName("u:${{ secrets.pw }}@host")).toBe("");
28
+ // A leading digit / spaces are not a valid secret name.
29
+ expect(parseSecretName("1bad")).toBe("");
30
+ expect(parseSecretName("not a name")).toBe("");
31
+ });
32
+ });
33
+
34
+ describe("toSecretTemplate", () => {
35
+ it("wraps a name in the canonical template", () => {
36
+ expect(toSecretTemplate("api")).toBe("${{ secrets.api }}");
37
+ });
38
+ it("returns empty for an empty name", () => {
39
+ expect(toSecretTemplate("")).toBe("");
40
+ });
41
+ });
42
+
43
+ describe("objectToRows / rowsToObject round-trip", () => {
44
+ it("converts a mapping to rows and back", () => {
45
+ const mapping = {
46
+ API_TOKEN: "${{ secrets.jira_token }}",
47
+ DB: "${{ secrets.db_pass }}",
48
+ };
49
+ const rows = objectToRows(mapping);
50
+ expect(rows).toEqual([
51
+ { envName: "API_TOKEN", secretName: "jira_token" },
52
+ { envName: "DB", secretName: "db_pass" },
53
+ ]);
54
+ expect(rowsToObject(rows)).toEqual(mapping);
55
+ });
56
+
57
+ it("drops incomplete rows (empty env name or secret) on serialize", () => {
58
+ const rows = [
59
+ { envName: "A", secretName: "alpha" },
60
+ { envName: "", secretName: "beta" },
61
+ { envName: "C", secretName: "" },
62
+ ];
63
+ expect(rowsToObject(rows)).toEqual({ A: "${{ secrets.alpha }}" });
64
+ });
65
+
66
+ it("trims whitespace in env and secret names", () => {
67
+ const rows = [{ envName: " TOKEN ", secretName: " tok " }];
68
+ expect(rowsToObject(rows)).toEqual({ TOKEN: "${{ secrets.tok }}" });
69
+ });
70
+ });
71
+
72
+ describe("unknownSecretNames", () => {
73
+ const rows: SecretEnvRow[] = [
74
+ { envName: "A", secretName: "alpha" },
75
+ { envName: "B", secretName: "beta" },
76
+ ];
77
+
78
+ it("returns the referenced names that are not in the available list", () => {
79
+ const result = unknownSecretNames({ rows, secretNames: ["alpha"] });
80
+ expect(result).toEqual(new Set(["beta"]));
81
+ });
82
+
83
+ it("returns an empty set when the list is undefined (still loading)", () => {
84
+ expect(unknownSecretNames({ rows, secretNames: undefined })).toEqual(
85
+ new Set(),
86
+ );
87
+ });
88
+
89
+ it("returns an empty set when the list is empty (treated as unknown list)", () => {
90
+ expect(unknownSecretNames({ rows, secretNames: [] })).toEqual(new Set());
91
+ });
92
+
93
+ it("returns an empty set when every referenced name is known", () => {
94
+ expect(
95
+ unknownSecretNames({ rows, secretNames: ["alpha", "beta", "gamma"] }),
96
+ ).toEqual(new Set());
97
+ });
98
+
99
+ it("ignores blank / whitespace-only secret names", () => {
100
+ const withBlank: SecretEnvRow[] = [
101
+ { envName: "A", secretName: "" },
102
+ { envName: "B", secretName: " " },
103
+ { envName: "C", secretName: "ghost" },
104
+ ];
105
+ expect(
106
+ unknownSecretNames({ rows: withBlank, secretNames: ["alpha"] }),
107
+ ).toEqual(new Set(["ghost"]));
108
+ });
109
+
110
+ it("de-duplicates a name referenced by multiple rows", () => {
111
+ const dupes: SecretEnvRow[] = [
112
+ { envName: "A", secretName: "ghost" },
113
+ { envName: "B", secretName: "ghost" },
114
+ ];
115
+ expect(unknownSecretNames({ rows: dupes, secretNames: ["alpha"] })).toEqual(
116
+ new Set(["ghost"]),
117
+ );
118
+ });
119
+
120
+ it("trims whitespace before comparing against the list", () => {
121
+ const padded: SecretEnvRow[] = [{ envName: "A", secretName: " alpha " }];
122
+ expect(
123
+ unknownSecretNames({ rows: padded, secretNames: ["alpha"] }),
124
+ ).toEqual(new Set());
125
+ });
126
+ });
@@ -0,0 +1,87 @@
1
+ /**
2
+ * Pure parse/serialize helpers for {@link SecretEnvEditor}. Kept separate
3
+ * so they can be unit-tested without rendering React.
4
+ *
5
+ * The stored shape is `{ ENV_NAME: "${{ secrets.NAME }}" }`; the editor
6
+ * works with rows of `{ envName, secretName }`.
7
+ */
8
+
9
+ export interface SecretEnvRow {
10
+ envName: string;
11
+ secretName: string;
12
+ }
13
+
14
+ const TEMPLATE_RE = /^\s*\$\{\{\s*secrets\.([a-zA-Z0-9_-]+)\s*\}\}\s*$/;
15
+ // A pure bare secret name (mirrors SECRET_NAME_REGEX in @checkstack/secrets-common).
16
+ const BARE_NAME_RE = /^\s*([a-zA-Z][a-zA-Z0-9_-]*)\s*$/;
17
+
18
+ /**
19
+ * Extract the secret name for display from a stored mapping value. Accepts
20
+ * either the canonical `${{ secrets.NAME }}` template OR a legacy / YAML
21
+ * shorthand bare secret name (the schema tolerates and normalizes bare names
22
+ * on write, so existing data may still carry one). Returns "" for anything
23
+ * that is neither (e.g. inline interpolation), so the picker stays empty.
24
+ */
25
+ export function parseSecretName(template: string): string {
26
+ const templateMatch = TEMPLATE_RE.exec(template);
27
+ if (templateMatch) return templateMatch[1];
28
+ const bareMatch = BARE_NAME_RE.exec(template);
29
+ return bareMatch?.[1] ?? "";
30
+ }
31
+
32
+ /** Render a secret name as its canonical `${{ secrets.NAME }}` template. */
33
+ export function toSecretTemplate(secretName: string): string {
34
+ return secretName ? `\${{ secrets.${secretName} }}` : "";
35
+ }
36
+
37
+ export function objectToRows(
38
+ value: Record<string, string>,
39
+ ): SecretEnvRow[] {
40
+ return Object.entries(value).map(([envName, template]) => ({
41
+ envName,
42
+ secretName: parseSecretName(template),
43
+ }));
44
+ }
45
+
46
+ export function rowsToObject(rows: SecretEnvRow[]): Record<string, string> {
47
+ const out: Record<string, string> = {};
48
+ for (const row of rows) {
49
+ // Drop incomplete rows (empty env name or secret) on serialize.
50
+ if (row.envName.trim() === "" || row.secretName.trim() === "") continue;
51
+ out[row.envName.trim()] = toSecretTemplate(row.secretName.trim());
52
+ }
53
+ return out;
54
+ }
55
+
56
+ /**
57
+ * Given the editor rows and the list of secret names known to exist (from
58
+ * the secrets plugin's `listSecretNames`), return the de-duplicated set of
59
+ * referenced secret names that are NOT in that list.
60
+ *
61
+ * Used to surface a non-blocking "this secret doesn't exist" warning on a
62
+ * row — the name may have been deleted/renamed, or be created later, so it
63
+ * round-trips regardless. Returns an empty set when:
64
+ * - `secretNames` is `undefined` (still loading; we don't warn early), or
65
+ * - `secretNames` is empty (treated as "list unknown", same as loading), or
66
+ * - every referenced name is known.
67
+ *
68
+ * Blank secret names are ignored (a half-typed row isn't an error yet).
69
+ */
70
+ export function unknownSecretNames({
71
+ rows,
72
+ secretNames,
73
+ }: {
74
+ rows: SecretEnvRow[];
75
+ secretNames: string[] | undefined;
76
+ }): Set<string> {
77
+ // No list to validate against → never warn (loading / unavailable).
78
+ if (!secretNames || secretNames.length === 0) return new Set();
79
+ const known = new Set(secretNames);
80
+ const unknown = new Set<string>();
81
+ for (const row of rows) {
82
+ const name = row.secretName.trim();
83
+ if (name === "") continue;
84
+ if (!known.has(name)) unknown.add(name);
85
+ }
86
+ return unknown;
87
+ }
@@ -1,4 +1,5 @@
1
- import type { TemplateProperty, ShellEnvVar } from "../CodeEditor";
1
+ import type React from "react";
2
+ import type { TemplateProperty, ShellEnvVar, AcquireTypes } from "../CodeEditor";
2
3
  import type { TemplateCompletionProvider } from "../TemplateValueInput";
3
4
  import type { EditorType } from "@checkstack/common";
4
5
 
@@ -26,8 +27,37 @@ export interface JsonSchemaProperty extends JsonSchemaPropertyCore<JsonSchemaPro
26
27
  "x-searchable"?: boolean; // Shows search input for filtering dropdown options
27
28
  "x-editor-types"?: EditorType[]; // Available editor types for multi-type input
28
29
  "x-hidden-when"?: Record<string, string[]>; // Conditionally hide based on sibling field values
30
+ "x-duration"?: boolean; // Render a DurationInput (single-unit duration object)
31
+ "x-script-testable"?: boolean; // Field is an inline script that can be tested in-UI
32
+ "x-secret-env"?: boolean; // Record field is a secret -> env mapping (SecretEnvEditor)
29
33
  }
30
34
 
35
+ /**
36
+ * Renders the inline script-test UI beneath a testable script field. The
37
+ * owning feature page supplies this; it owns the RPC call + sample-context
38
+ * state and typically renders a `ScriptTestPanel`. The form only decides
39
+ * *where* it appears (below any `x-script-testable` field whose selected
40
+ * editor type is a code language). Mirrors the callback-prop pattern used
41
+ * by `optionsResolvers` / `templateCompletionProvider`.
42
+ */
43
+ export type ScriptTestRenderer = (args: {
44
+ /** The field's id (form key). */
45
+ fieldId: string;
46
+ /** Editor language currently selected in the field. */
47
+ kind: "typescript" | "shell";
48
+ /** Current script source in the field. */
49
+ script: string;
50
+ /**
51
+ * The current value of the SIBLING secret→env mapping field (the field
52
+ * annotated `x-secret-env` within the same config object as the script),
53
+ * if any. DynamicForm locates it by the annotation — not by name — so the
54
+ * test panel can inject `__SECRET_<NAME>__` placeholders (or the operator's
55
+ * overrides) for the same secrets the real action declares. `undefined`
56
+ * when the config has no `x-secret-env` field or it's empty.
57
+ */
58
+ secretEnv?: Record<string, string>;
59
+ }) => React.ReactNode;
60
+
31
61
  /** Option returned by an options resolver */
32
62
  export interface ResolverOption {
33
63
  value: string;
@@ -98,6 +128,36 @@ export interface DynamicFormProps {
98
128
  * fields with a working example. Keyed by `EditorType`.
99
129
  */
100
130
  starterTemplates?: EditorStarterTemplates;
131
+ /**
132
+ * Optional renderer for the inline script-test panel. When supplied,
133
+ * fields flagged `x-script-testable` (whose selected editor type is a
134
+ * code language) render this beneath the editor so operators can run
135
+ * the script against a sample context. Omit it and no test UI appears.
136
+ */
137
+ scriptTestRenderer?: ScriptTestRenderer;
138
+ /**
139
+ * Optional list of secret NAMES (never values) for `x-secret-env` record
140
+ * fields. The owning page fetches these from the secrets plugin's
141
+ * `listSecretNames` and passes them here so the secret-env editor offers
142
+ * name autocomplete. Omit it and the editor still works as free text.
143
+ */
144
+ secretNames?: string[];
145
+ /**
146
+ * Optional lazy type-acquisition resolver forwarded to TS/JS editor-type
147
+ * fields. When supplied, the editor fetches + registers the `.d.ts` of any
148
+ * npm package the script imports, so `import { x } from "pkg"`
149
+ * autocompletes. Injected by the owning page (see
150
+ * `@checkstack/script-packages-frontend`).
151
+ */
152
+ acquireTypes?: AcquireTypes;
153
+ /** Install identity (lockfile hash); resets acquired types on a new install. */
154
+ acquireResetKey?: string;
155
+ /**
156
+ * Importable installed package names (already `@types/*`-free), forwarded to
157
+ * TS/JS editors so the import specifier itself autocompletes
158
+ * (`import {} from "lod"` -> `lodash`).
159
+ */
160
+ importablePackages?: string[];
101
161
  }
102
162
 
103
163
  /** Props for the FormField component */
@@ -114,6 +174,17 @@ export interface FormFieldProps {
114
174
  typeDefinitions?: string;
115
175
  shellEnvVars?: ShellEnvVar[];
116
176
  starterTemplates?: EditorStarterTemplates;
177
+ scriptTestRenderer?: ScriptTestRenderer;
178
+ secretNames?: string[];
179
+ acquireTypes?: AcquireTypes;
180
+ acquireResetKey?: string;
181
+ importablePackages?: string[];
182
+ /**
183
+ * Current value of the sibling `x-secret-env` mapping field within the
184
+ * SAME config object as this field, located by annotation. Threaded down
185
+ * so a testable script field can forward it to {@link ScriptTestRenderer}.
186
+ */
187
+ siblingSecretEnv?: Record<string, string>;
117
188
  /** Callback when value changes. Omit val to clear the field. */
118
189
  onChange: (val?: unknown) => void;
119
190
  }
@@ -69,6 +69,38 @@ export function isValueEmpty(
69
69
  return false;
70
70
  }
71
71
 
72
+ /**
73
+ * Locate the value of the secret→env mapping field within an object's
74
+ * properties by the `x-secret-env` annotation (NOT by a hard-coded field
75
+ * name), and return it. Used to feed the inline script-test panel the same
76
+ * `secretEnv` the sibling action declares, so a test injects placeholders /
77
+ * overrides for those secrets. Returns `undefined` when no `x-secret-env`
78
+ * field exists or its value isn't a record.
79
+ */
80
+ export function findSecretEnvSibling({
81
+ properties,
82
+ values,
83
+ }: {
84
+ properties: Record<string, JsonSchemaProperty> | undefined;
85
+ values: Record<string, unknown> | undefined;
86
+ }): Record<string, string> | undefined {
87
+ if (!properties || !values) return undefined;
88
+ for (const [key, propSchema] of Object.entries(properties)) {
89
+ if (propSchema["x-secret-env"] === true) {
90
+ const value = values[key];
91
+ if (value && typeof value === "object" && !Array.isArray(value)) {
92
+ const record: Record<string, string> = {};
93
+ for (const [k, v] of Object.entries(value)) {
94
+ if (typeof v === "string") record[k] = v;
95
+ }
96
+ return record;
97
+ }
98
+ return undefined;
99
+ }
100
+ }
101
+ return undefined;
102
+ }
103
+
72
104
  /** Sentinel value used to represent "None" selection in Select components */
73
105
  export const NONE_SENTINEL = "__none__";
74
106