@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,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
|
+
}
|
|
@@ -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={
|
|
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
|
-
<
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
</
|
|
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 {
|
|
6
|
+
import {
|
|
7
|
+
extractDefaults,
|
|
8
|
+
isValueEmpty,
|
|
9
|
+
isFieldHiddenByCondition,
|
|
10
|
+
findSecretEnvSibling,
|
|
11
|
+
} from "./utils";
|
|
7
12
|
import { FormField } from "./FormField";
|
|
8
13
|
|
|
9
14
|
/**
|
|
@@ -22,6 +27,11 @@ export const DynamicForm: React.FC<DynamicFormProps> = ({
|
|
|
22
27
|
typeDefinitions,
|
|
23
28
|
shellEnvVars,
|
|
24
29
|
starterTemplates,
|
|
30
|
+
scriptTestRenderer,
|
|
31
|
+
secretNames,
|
|
32
|
+
acquireTypes,
|
|
33
|
+
acquireResetKey,
|
|
34
|
+
importablePackages,
|
|
25
35
|
}) => {
|
|
26
36
|
// Track previous validity to avoid redundant callbacks
|
|
27
37
|
const prevValidRef = React.useRef<boolean | undefined>(undefined);
|
|
@@ -88,6 +98,14 @@ export const DynamicForm: React.FC<DynamicFormProps> = ({
|
|
|
88
98
|
);
|
|
89
99
|
}
|
|
90
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
|
+
|
|
91
109
|
return (
|
|
92
110
|
<div className="space-y-6">
|
|
93
111
|
{Object.entries(schema.properties)
|
|
@@ -121,6 +139,12 @@ export const DynamicForm: React.FC<DynamicFormProps> = ({
|
|
|
121
139
|
typeDefinitions={typeDefinitions}
|
|
122
140
|
shellEnvVars={shellEnvVars}
|
|
123
141
|
starterTemplates={starterTemplates}
|
|
142
|
+
scriptTestRenderer={scriptTestRenderer}
|
|
143
|
+
secretNames={secretNames}
|
|
144
|
+
acquireTypes={acquireTypes}
|
|
145
|
+
acquireResetKey={acquireResetKey}
|
|
146
|
+
importablePackages={importablePackages}
|
|
147
|
+
siblingSecretEnv={rootSecretEnv}
|
|
124
148
|
onChange={(val) => onChange({ ...value, [key]: val })}
|
|
125
149
|
/>
|
|
126
150
|
);
|
|
@@ -14,13 +14,20 @@ import {
|
|
|
14
14
|
Toggle,
|
|
15
15
|
ColorPicker,
|
|
16
16
|
TemplateValueInput,
|
|
17
|
+
DurationInput,
|
|
18
|
+
type DurationValue,
|
|
17
19
|
} from "../../index";
|
|
18
20
|
|
|
19
21
|
import type { FormFieldProps, JsonSchemaProperty } from "./types";
|
|
20
|
-
import {
|
|
22
|
+
import {
|
|
23
|
+
getCleanDescription,
|
|
24
|
+
NONE_SENTINEL,
|
|
25
|
+
findSecretEnvSibling,
|
|
26
|
+
} from "./utils";
|
|
21
27
|
import { DynamicOptionsField } from "./DynamicOptionsField";
|
|
22
28
|
import { JsonField } from "./JsonField";
|
|
23
29
|
import { MultiTypeEditorField } from "./MultiTypeEditorField";
|
|
30
|
+
import { SecretEnvEditor } from "./SecretEnvEditor";
|
|
24
31
|
|
|
25
32
|
/**
|
|
26
33
|
* Recursive field renderer that handles all supported JSON Schema types.
|
|
@@ -38,6 +45,12 @@ export const FormField: React.FC<FormFieldProps> = ({
|
|
|
38
45
|
typeDefinitions,
|
|
39
46
|
shellEnvVars,
|
|
40
47
|
starterTemplates,
|
|
48
|
+
scriptTestRenderer,
|
|
49
|
+
secretNames,
|
|
50
|
+
acquireTypes,
|
|
51
|
+
acquireResetKey,
|
|
52
|
+
importablePackages,
|
|
53
|
+
siblingSecretEnv,
|
|
41
54
|
onChange,
|
|
42
55
|
}) => {
|
|
43
56
|
const description = propSchema.description || "";
|
|
@@ -74,6 +87,34 @@ export const FormField: React.FC<FormFieldProps> = ({
|
|
|
74
87
|
return <></>;
|
|
75
88
|
}
|
|
76
89
|
|
|
90
|
+
// Duration field — render the DurationInput (single-unit duration
|
|
91
|
+
// object). Marked via `x-duration: true` or `format: "duration"`. This
|
|
92
|
+
// branch is intentionally additive and sits before the generic union /
|
|
93
|
+
// object handlers so a `for:` / threshold-window config renders the
|
|
94
|
+
// widget rather than the raw oneOf discriminator picker.
|
|
95
|
+
const isDuration =
|
|
96
|
+
propSchema["x-duration"] === true || propSchema.format === "duration";
|
|
97
|
+
if (isDuration) {
|
|
98
|
+
const cleanDesc = getCleanDescription(description);
|
|
99
|
+
return (
|
|
100
|
+
<div className="space-y-2">
|
|
101
|
+
<div>
|
|
102
|
+
<Label htmlFor={id}>
|
|
103
|
+
{label} {isRequired && "*"}
|
|
104
|
+
</Label>
|
|
105
|
+
{cleanDesc && (
|
|
106
|
+
<p className="text-sm text-muted-foreground mt-0.5">{cleanDesc}</p>
|
|
107
|
+
)}
|
|
108
|
+
</div>
|
|
109
|
+
<DurationInput
|
|
110
|
+
id={id}
|
|
111
|
+
value={value as DurationValue | undefined}
|
|
112
|
+
onChange={(next) => onChange(next)}
|
|
113
|
+
/>
|
|
114
|
+
</div>
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
|
|
77
118
|
// Enum handling
|
|
78
119
|
if (propSchema.enum) {
|
|
79
120
|
const cleanDesc = getCleanDescription(description);
|
|
@@ -137,6 +178,16 @@ export const FormField: React.FC<FormFieldProps> = ({
|
|
|
137
178
|
typeDefinitions={typeDefinitions}
|
|
138
179
|
shellEnvVars={shellEnvVars}
|
|
139
180
|
starterTemplates={starterTemplates}
|
|
181
|
+
scriptTestRenderer={
|
|
182
|
+
propSchema["x-script-testable"] === true
|
|
183
|
+
? scriptTestRenderer
|
|
184
|
+
: undefined
|
|
185
|
+
}
|
|
186
|
+
acquireTypes={acquireTypes}
|
|
187
|
+
acquireResetKey={acquireResetKey}
|
|
188
|
+
importablePackages={importablePackages}
|
|
189
|
+
fieldId={id}
|
|
190
|
+
siblingSecretEnv={siblingSecretEnv}
|
|
140
191
|
onChange={onChange as (val: string | undefined) => void}
|
|
141
192
|
/>
|
|
142
193
|
);
|
|
@@ -331,6 +382,34 @@ export const FormField: React.FC<FormFieldProps> = ({
|
|
|
331
382
|
}
|
|
332
383
|
|
|
333
384
|
// Dictionary/Record (headers)
|
|
385
|
+
// Secret -> env mapping: a dedicated editor (env name + secret-name
|
|
386
|
+
// picker) instead of the raw JSON record fallback.
|
|
387
|
+
if (
|
|
388
|
+
propSchema.type === "object" &&
|
|
389
|
+
propSchema.additionalProperties &&
|
|
390
|
+
propSchema["x-secret-env"]
|
|
391
|
+
) {
|
|
392
|
+
const cleanDesc = getCleanDescription(description);
|
|
393
|
+
return (
|
|
394
|
+
<div className="space-y-2">
|
|
395
|
+
<div>
|
|
396
|
+
<Label htmlFor={id}>
|
|
397
|
+
{label} {isRequired && "*"}
|
|
398
|
+
</Label>
|
|
399
|
+
{cleanDesc && (
|
|
400
|
+
<p className="text-sm text-muted-foreground mt-0.5">{cleanDesc}</p>
|
|
401
|
+
)}
|
|
402
|
+
</div>
|
|
403
|
+
<SecretEnvEditor
|
|
404
|
+
id={id}
|
|
405
|
+
value={(value as Record<string, string> | undefined) ?? {}}
|
|
406
|
+
secretNames={secretNames}
|
|
407
|
+
onChange={(next) => onChange(next)}
|
|
408
|
+
/>
|
|
409
|
+
</div>
|
|
410
|
+
);
|
|
411
|
+
}
|
|
412
|
+
|
|
334
413
|
if (propSchema.type === "object" && propSchema.additionalProperties) {
|
|
335
414
|
const cleanDesc = getCleanDescription(description);
|
|
336
415
|
return (
|
|
@@ -355,6 +434,12 @@ export const FormField: React.FC<FormFieldProps> = ({
|
|
|
355
434
|
|
|
356
435
|
// Object (Nested Form)
|
|
357
436
|
if (propSchema.type === "object" && propSchema.properties) {
|
|
437
|
+
// Resolve the secret→env sibling within THIS object so a nested
|
|
438
|
+
// testable script field forwards the right mapping to the test panel.
|
|
439
|
+
const nestedSecretEnv = findSecretEnvSibling({
|
|
440
|
+
properties: propSchema.properties,
|
|
441
|
+
values: value as Record<string, unknown> | undefined,
|
|
442
|
+
});
|
|
358
443
|
return (
|
|
359
444
|
<div className="space-y-4 p-4 border rounded-lg bg-muted/30">
|
|
360
445
|
<p className="text-sm font-semibold">{label}</p>
|
|
@@ -373,6 +458,12 @@ export const FormField: React.FC<FormFieldProps> = ({
|
|
|
373
458
|
typeDefinitions={typeDefinitions}
|
|
374
459
|
shellEnvVars={shellEnvVars}
|
|
375
460
|
starterTemplates={starterTemplates}
|
|
461
|
+
scriptTestRenderer={scriptTestRenderer}
|
|
462
|
+
secretNames={secretNames}
|
|
463
|
+
acquireTypes={acquireTypes}
|
|
464
|
+
acquireResetKey={acquireResetKey}
|
|
465
|
+
importablePackages={importablePackages}
|
|
466
|
+
siblingSecretEnv={nestedSecretEnv}
|
|
376
467
|
onChange={(val) =>
|
|
377
468
|
onChange({ ...(value as Record<string, unknown>), [key]: val })
|
|
378
469
|
}
|
|
@@ -485,6 +576,11 @@ export const FormField: React.FC<FormFieldProps> = ({
|
|
|
485
576
|
typeDefinitions={typeDefinitions}
|
|
486
577
|
shellEnvVars={shellEnvVars}
|
|
487
578
|
starterTemplates={starterTemplates}
|
|
579
|
+
scriptTestRenderer={scriptTestRenderer}
|
|
580
|
+
secretNames={secretNames}
|
|
581
|
+
acquireTypes={acquireTypes}
|
|
582
|
+
acquireResetKey={acquireResetKey}
|
|
583
|
+
importablePackages={importablePackages}
|
|
488
584
|
onChange={(val) => {
|
|
489
585
|
const next = [...(items as unknown[])];
|
|
490
586
|
next[index] = val;
|
|
@@ -543,6 +639,12 @@ export const FormField: React.FC<FormFieldProps> = ({
|
|
|
543
639
|
const displayDiscriminatorField =
|
|
544
640
|
discriminatorField.charAt(0).toUpperCase() + discriminatorField.slice(1);
|
|
545
641
|
|
|
642
|
+
// Secret→env sibling within the selected variant's object.
|
|
643
|
+
const variantSecretEnv = findSecretEnvSibling({
|
|
644
|
+
properties: selectedVariant.properties,
|
|
645
|
+
values: currentValue,
|
|
646
|
+
});
|
|
647
|
+
|
|
546
648
|
return (
|
|
547
649
|
<div className="space-y-3 p-3 border rounded-lg bg-background">
|
|
548
650
|
{/* Discriminator selector */}
|
|
@@ -617,6 +719,12 @@ export const FormField: React.FC<FormFieldProps> = ({
|
|
|
617
719
|
typeDefinitions={typeDefinitions}
|
|
618
720
|
shellEnvVars={shellEnvVars}
|
|
619
721
|
starterTemplates={starterTemplates}
|
|
722
|
+
scriptTestRenderer={scriptTestRenderer}
|
|
723
|
+
secretNames={secretNames}
|
|
724
|
+
acquireTypes={acquireTypes}
|
|
725
|
+
acquireResetKey={acquireResetKey}
|
|
726
|
+
importablePackages={importablePackages}
|
|
727
|
+
siblingSecretEnv={variantSecretEnv}
|
|
620
728
|
onChange={(val) => onChange({ ...currentValue, [key]: val })}
|
|
621
729
|
/>
|
|
622
730
|
))}
|
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
import React from "react";
|
|
2
2
|
import { Label } from "../Label";
|
|
3
3
|
import { Textarea } from "../Textarea";
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
CodeEditor,
|
|
6
|
+
type TemplateProperty,
|
|
7
|
+
type AcquireTypes,
|
|
8
|
+
} from "../CodeEditor";
|
|
5
9
|
import {
|
|
6
10
|
Select,
|
|
7
11
|
SelectContent,
|
|
@@ -17,7 +21,11 @@ import {
|
|
|
17
21
|
parseFormData,
|
|
18
22
|
EDITOR_TYPE_LABELS,
|
|
19
23
|
} from "./utils";
|
|
20
|
-
import type {
|
|
24
|
+
import type {
|
|
25
|
+
EditorStarterTemplates,
|
|
26
|
+
ScriptTestRenderer,
|
|
27
|
+
ShellEnvVar,
|
|
28
|
+
} from "./types";
|
|
21
29
|
import { pickStarterForEmptyField } from "./starterTemplateSelector";
|
|
22
30
|
|
|
23
31
|
export interface MultiTypeEditorFieldProps {
|
|
@@ -54,6 +62,34 @@ export interface MultiTypeEditorFieldProps {
|
|
|
54
62
|
* working example instead of a blank canvas.
|
|
55
63
|
*/
|
|
56
64
|
starterTemplates?: EditorStarterTemplates;
|
|
65
|
+
/**
|
|
66
|
+
* Optional renderer for the inline script-test panel. When supplied (the
|
|
67
|
+
* field is `x-script-testable`), it is rendered beneath the editor for
|
|
68
|
+
* `typescript` / `shell` modes, with the currently-selected language as
|
|
69
|
+
* `kind`. The owning page wires it to the `testScript` RPC.
|
|
70
|
+
*/
|
|
71
|
+
scriptTestRenderer?: ScriptTestRenderer;
|
|
72
|
+
/**
|
|
73
|
+
* Optional lazy type-acquisition resolver, forwarded to the TS/JS
|
|
74
|
+
* `CodeEditor` so imported npm packages autocomplete (lazy ATA).
|
|
75
|
+
*/
|
|
76
|
+
acquireTypes?: AcquireTypes;
|
|
77
|
+
/** Install identity (lockfile hash); resets acquired types on a new install. */
|
|
78
|
+
acquireResetKey?: string;
|
|
79
|
+
/**
|
|
80
|
+
* Importable installed package names (`@types/*`-free), forwarded to the
|
|
81
|
+
* TS/JS `CodeEditor` so the import specifier itself autocompletes.
|
|
82
|
+
*/
|
|
83
|
+
importablePackages?: string[];
|
|
84
|
+
/** Form key of this field, forwarded to {@link scriptTestRenderer}. */
|
|
85
|
+
fieldId?: string;
|
|
86
|
+
/**
|
|
87
|
+
* Current value of the sibling `x-secret-env` mapping (located by
|
|
88
|
+
* annotation in the parent config object), forwarded to
|
|
89
|
+
* {@link scriptTestRenderer} so the test panel injects placeholders /
|
|
90
|
+
* overrides for the same secrets the action declares.
|
|
91
|
+
*/
|
|
92
|
+
siblingSecretEnv?: Record<string, string>;
|
|
57
93
|
/** Callback when value changes */
|
|
58
94
|
onChange: (value: string | undefined) => void;
|
|
59
95
|
}
|
|
@@ -74,6 +110,12 @@ export const MultiTypeEditorField: React.FC<MultiTypeEditorFieldProps> = ({
|
|
|
74
110
|
typeDefinitions,
|
|
75
111
|
shellEnvVars,
|
|
76
112
|
starterTemplates,
|
|
113
|
+
scriptTestRenderer,
|
|
114
|
+
acquireTypes,
|
|
115
|
+
acquireResetKey,
|
|
116
|
+
importablePackages,
|
|
117
|
+
fieldId,
|
|
118
|
+
siblingSecretEnv,
|
|
77
119
|
onChange,
|
|
78
120
|
}) => {
|
|
79
121
|
// Detect initial editor type from value
|
|
@@ -352,6 +394,9 @@ export const MultiTypeEditorField: React.FC<MultiTypeEditorFieldProps> = ({
|
|
|
352
394
|
language="javascript"
|
|
353
395
|
minHeight="150px"
|
|
354
396
|
typeDefinitions={typeDefinitions}
|
|
397
|
+
acquireTypes={acquireTypes}
|
|
398
|
+
acquireResetKey={acquireResetKey}
|
|
399
|
+
importablePackages={importablePackages}
|
|
355
400
|
/>
|
|
356
401
|
)}
|
|
357
402
|
|
|
@@ -363,6 +408,9 @@ export const MultiTypeEditorField: React.FC<MultiTypeEditorFieldProps> = ({
|
|
|
363
408
|
language="typescript"
|
|
364
409
|
minHeight="150px"
|
|
365
410
|
typeDefinitions={typeDefinitions}
|
|
411
|
+
acquireTypes={acquireTypes}
|
|
412
|
+
acquireResetKey={acquireResetKey}
|
|
413
|
+
importablePackages={importablePackages}
|
|
366
414
|
/>
|
|
367
415
|
)}
|
|
368
416
|
|
|
@@ -376,6 +424,23 @@ export const MultiTypeEditorField: React.FC<MultiTypeEditorFieldProps> = ({
|
|
|
376
424
|
shellEnvVars={shellEnvVars}
|
|
377
425
|
/>
|
|
378
426
|
)}
|
|
427
|
+
|
|
428
|
+
{/* Inline script-test panel for testable code fields. Shell maps to
|
|
429
|
+
the `shell` test kind; both typescript and javascript map to the
|
|
430
|
+
`typescript` runner kind (the ESM runner handles both). */}
|
|
431
|
+
{scriptTestRenderer !== undefined &&
|
|
432
|
+
(selectedType === "typescript" ||
|
|
433
|
+
selectedType === "javascript" ||
|
|
434
|
+
selectedType === "shell") &&
|
|
435
|
+
scriptTestRenderer({
|
|
436
|
+
fieldId: fieldId ?? id,
|
|
437
|
+
kind: selectedType === "shell" ? "shell" : "typescript",
|
|
438
|
+
script: value ?? "",
|
|
439
|
+
secretEnv:
|
|
440
|
+
siblingSecretEnv && Object.keys(siblingSecretEnv).length > 0
|
|
441
|
+
? siblingSecretEnv
|
|
442
|
+
: undefined,
|
|
443
|
+
})}
|
|
379
444
|
</div>
|
|
380
445
|
);
|
|
381
446
|
};
|