@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.
- package/.storybook/main.ts +43 -0
- package/CHANGELOG.md +181 -0
- package/package.json +4 -4
- package/scripts/generate-stdlib-types.ts +23 -0
- package/src/components/ActionCard.tsx +96 -8
- package/src/components/CodeEditor/CodeEditor.tsx +95 -14
- package/src/components/CodeEditor/TypefoxEditor.tsx +279 -123
- package/src/components/CodeEditor/generated/builtin-modules.json +1 -0
- package/src/components/CodeEditor/importSpecifiers.test.ts +286 -0
- package/src/components/CodeEditor/importSpecifiers.ts +267 -0
- package/src/components/CodeEditor/index.ts +24 -0
- package/src/components/CodeEditor/monacoTsService.ts +217 -0
- package/src/components/CodeEditor/popoutTitle.test.ts +37 -0
- package/src/components/CodeEditor/popoutTitle.ts +31 -0
- package/src/components/CodeEditor/scriptDiagnostics.test.ts +135 -0
- package/src/components/CodeEditor/scriptDiagnostics.ts +172 -0
- package/src/components/CodeEditor/types.ts +59 -0
- package/src/components/CodeEditor/validateScripts.ts +132 -0
- package/src/components/Dialog.tsx +32 -11
- package/src/components/DurationInput.tsx +121 -0
- package/src/components/DynamicForm/DynamicForm.tsx +25 -1
- package/src/components/DynamicForm/FormField.tsx +109 -1
- package/src/components/DynamicForm/MultiTypeEditorField.tsx +67 -2
- package/src/components/DynamicForm/SecretEnvEditor.tsx +315 -0
- package/src/components/DynamicForm/index.ts +6 -0
- package/src/components/DynamicForm/secretEnv.logic.test.ts +126 -0
- package/src/components/DynamicForm/secretEnv.logic.ts +87 -0
- package/src/components/DynamicForm/types.ts +72 -1
- package/src/components/DynamicForm/utils.ts +32 -0
- package/src/components/Popover.tsx +6 -1
- package/src/components/ScriptTestPanel.logic.test.ts +139 -0
- package/src/components/ScriptTestPanel.logic.ts +137 -0
- package/src/components/ScriptTestPanel.tsx +394 -0
- package/src/components/Sheet.tsx +21 -6
- package/src/components/TimeOfDayInput.tsx +116 -0
- package/src/components/comboboxInteraction.ts +39 -0
- package/src/components/portalContainer.ts +24 -0
- package/src/index.ts +4 -0
- package/stories/ActionCard.stories.tsx +60 -0
- package/stories/CodeEditor.stories.tsx +47 -2
- package/stories/DurationInput.stories.tsx +59 -0
- package/stories/ScriptTestPanel.stories.tsx +106 -0
- package/stories/SecretEnvEditor.stories.tsx +80 -0
- package/stories/TimeOfDayInput.stories.tsx +34 -0
- 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
|
|
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
|
|