@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.
- package/.storybook/main.ts +43 -0
- package/CHANGELOG.md +565 -0
- package/package.json +15 -7
- package/scripts/generate-stdlib-types.ts +25 -2
- package/src/components/ActionCard.tsx +309 -0
- package/src/components/CodeEditor/CodeEditor.tsx +132 -9
- package/src/components/CodeEditor/TypefoxEditor.tsx +1024 -0
- package/src/components/CodeEditor/bracketKeyGroups.test.ts +120 -0
- package/src/components/CodeEditor/bracketKeyGroups.ts +205 -0
- package/src/components/CodeEditor/generateTypeDefinitions.ts +4 -4
- 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 +26 -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/scriptContext.test.ts +41 -0
- package/src/components/CodeEditor/scriptContext.ts +76 -1
- package/src/components/CodeEditor/scriptDiagnostics.test.ts +135 -0
- package/src/components/CodeEditor/scriptDiagnostics.ts +172 -0
- package/src/components/CodeEditor/templateValidation.ts +51 -0
- package/src/components/CodeEditor/types.ts +168 -0
- package/src/components/CodeEditor/validateJsonTemplate.test.ts +61 -0
- package/src/components/CodeEditor/validateJsonTemplate.ts +26 -0
- package/src/components/CodeEditor/validateScripts.ts +132 -0
- package/src/components/CodeEditor/validateXmlTemplate.test.ts +34 -0
- package/src/components/CodeEditor/validateXmlTemplate.ts +35 -0
- package/src/components/CodeEditor/validateYamlTemplate.test.ts +39 -0
- package/src/components/CodeEditor/validateYamlTemplate.ts +28 -0
- package/src/components/Dialog.tsx +32 -11
- package/src/components/DurationInput.tsx +121 -0
- package/src/components/DynamicForm/DynamicForm.tsx +27 -1
- package/src/components/DynamicForm/FormField.tsx +138 -10
- package/src/components/DynamicForm/KeyValueEditor.tsx +2 -169
- package/src/components/DynamicForm/MultiTypeEditorField.tsx +83 -9
- 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 +83 -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/TemplateInput.tsx +104 -0
- package/src/components/TemplateInputToggle.tsx +111 -0
- package/src/components/TemplateValueInput.test.ts +98 -0
- package/src/components/TemplateValueInput.tsx +470 -0
- package/src/components/TimeOfDayInput.tsx +116 -0
- package/src/components/VariablePicker.tsx +271 -0
- package/src/components/comboboxInteraction.ts +39 -0
- package/src/components/portalContainer.ts +24 -0
- package/src/hooks/useInitOnceForKey.test.ts +27 -0
- package/src/hooks/useInitOnceForKey.ts +21 -18
- package/src/index.ts +9 -0
- package/stories/ActionCard.stories.tsx +122 -0
- package/stories/Alert.stories.tsx +5 -5
- 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/TemplateInputToggle.stories.tsx +77 -0
- package/stories/TemplateValueInput.stories.tsx +65 -0
- package/stories/TimeOfDayInput.stories.tsx +34 -0
- package/stories/VariablePicker.stories.tsx +109 -0
- package/tsconfig.json +1 -0
- package/src/components/CodeEditor/MonacoEditor.tsx +0 -616
- package/src/components/CodeEditor/monacoStdlib.ts +0 -62
- package/src/components/CodeEditor/monacoWorkers.ts +0 -118
|
@@ -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
|
|
|
@@ -2,6 +2,7 @@ import * as React from "react";
|
|
|
2
2
|
import * as PopoverPrimitive from "@radix-ui/react-popover";
|
|
3
3
|
import { cn } from "../utils";
|
|
4
4
|
import { usePerformance } from "./PerformanceProvider";
|
|
5
|
+
import { usePortalContainer } from "./portalContainer";
|
|
5
6
|
|
|
6
7
|
const Popover = PopoverPrimitive.Root;
|
|
7
8
|
const PopoverTrigger = PopoverPrimitive.Trigger;
|
|
@@ -17,8 +18,12 @@ const PopoverContent = React.forwardRef<
|
|
|
17
18
|
PopoverContentProps
|
|
18
19
|
>(({ className, align = "end", sideOffset = 8, ...props }, ref) => {
|
|
19
20
|
const { isLowPower } = usePerformance();
|
|
21
|
+
// When inside a modal Sheet/Dialog, portal into its content so the dialog's
|
|
22
|
+
// scroll-lock doesn't block this popover's internal scroll. Outside a
|
|
23
|
+
// Sheet/Dialog the container is null and Radix portals to `body` as usual.
|
|
24
|
+
const portalContainer = usePortalContainer();
|
|
20
25
|
return (
|
|
21
|
-
<PopoverPrimitive.Portal>
|
|
26
|
+
<PopoverPrimitive.Portal container={portalContainer ?? undefined}>
|
|
22
27
|
<PopoverPrimitive.Content
|
|
23
28
|
ref={ref}
|
|
24
29
|
align={align}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
buildSecretOverrides,
|
|
4
|
+
distinctSecretNames,
|
|
5
|
+
formatReturnValue,
|
|
6
|
+
hasNoOutput,
|
|
7
|
+
isFailedResult,
|
|
8
|
+
rejectionResult,
|
|
9
|
+
validateSampleContextJson,
|
|
10
|
+
type ScriptTestPanelResult,
|
|
11
|
+
} from "./ScriptTestPanel.logic";
|
|
12
|
+
|
|
13
|
+
const base: ScriptTestPanelResult = {
|
|
14
|
+
stdout: "",
|
|
15
|
+
stderr: "",
|
|
16
|
+
durationMs: 10,
|
|
17
|
+
timedOut: false,
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
describe("isFailedResult", () => {
|
|
21
|
+
test("a clean result is not a failure", () => {
|
|
22
|
+
expect(isFailedResult({ ...base, result: { ok: true } })).toBe(false);
|
|
23
|
+
});
|
|
24
|
+
test("an error result is a failure", () => {
|
|
25
|
+
expect(isFailedResult({ ...base, error: "boom" })).toBe(true);
|
|
26
|
+
});
|
|
27
|
+
test("a timed-out result is a failure", () => {
|
|
28
|
+
expect(isFailedResult({ ...base, timedOut: true })).toBe(true);
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
describe("formatReturnValue", () => {
|
|
33
|
+
test("pretty-prints an object", () => {
|
|
34
|
+
expect(formatReturnValue({ a: 1 })).toBe('{\n "a": 1\n}');
|
|
35
|
+
});
|
|
36
|
+
test("renders undefined explicitly", () => {
|
|
37
|
+
expect(formatReturnValue(undefined)).toBe("undefined");
|
|
38
|
+
});
|
|
39
|
+
test("falls back to String() for non-serialisable values", () => {
|
|
40
|
+
const circular: Record<string, unknown> = {};
|
|
41
|
+
circular.self = circular;
|
|
42
|
+
expect(formatReturnValue(circular)).toBe("[object Object]");
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
describe("hasNoOutput", () => {
|
|
47
|
+
test("true when nothing was produced", () => {
|
|
48
|
+
expect(hasNoOutput(base)).toBe(true);
|
|
49
|
+
});
|
|
50
|
+
test("false when there is a return value", () => {
|
|
51
|
+
expect(hasNoOutput({ ...base, result: null })).toBe(false);
|
|
52
|
+
});
|
|
53
|
+
test("false when there is stdout", () => {
|
|
54
|
+
expect(hasNoOutput({ ...base, stdout: "hi" })).toBe(false);
|
|
55
|
+
});
|
|
56
|
+
test("false when there is an error", () => {
|
|
57
|
+
expect(hasNoOutput({ ...base, error: "x" })).toBe(false);
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
describe("validateSampleContextJson", () => {
|
|
62
|
+
test("empty value is valid (null)", () => {
|
|
63
|
+
expect(validateSampleContextJson(" ")).toBeNull();
|
|
64
|
+
});
|
|
65
|
+
test("valid JSON is valid (null)", () => {
|
|
66
|
+
expect(validateSampleContextJson('{"a":1}')).toBeNull();
|
|
67
|
+
});
|
|
68
|
+
test("malformed JSON returns a message", () => {
|
|
69
|
+
expect(validateSampleContextJson("{ not json")).not.toBeNull();
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
describe("rejectionResult", () => {
|
|
74
|
+
test("wraps a thrown Error into a failure result", () => {
|
|
75
|
+
const r = rejectionResult(new Error("network down"));
|
|
76
|
+
expect(r.error).toBe("network down");
|
|
77
|
+
expect(isFailedResult(r)).toBe(true);
|
|
78
|
+
expect(r.timedOut).toBe(false);
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
describe("distinctSecretNames", () => {
|
|
83
|
+
test("returns [] for an absent or empty mapping", () => {
|
|
84
|
+
expect(distinctSecretNames(undefined)).toEqual([]);
|
|
85
|
+
expect(distinctSecretNames({})).toEqual([]);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("extracts distinct secret names from templates, first-seen order", () => {
|
|
89
|
+
expect(
|
|
90
|
+
distinctSecretNames({
|
|
91
|
+
API_TOKEN: "${{ secrets.jira_token }}",
|
|
92
|
+
DB: "${{ secrets.db_pass }}",
|
|
93
|
+
ALSO: "${{ secrets.jira_token }}",
|
|
94
|
+
}),
|
|
95
|
+
).toEqual(["jira_token", "db_pass"]);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test("tolerates a legacy bare secret name as a value", () => {
|
|
99
|
+
expect(distinctSecretNames({ secret: "SECRET" })).toEqual(["SECRET"]);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test("ignores values that are neither a template nor a bare name", () => {
|
|
103
|
+
expect(distinctSecretNames({ X: "not a name" })).toEqual([]);
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
describe("buildSecretOverrides", () => {
|
|
108
|
+
const secretEnv = {
|
|
109
|
+
API_TOKEN: "${{ secrets.jira_token }}",
|
|
110
|
+
DB: "${{ secrets.db_pass }}",
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
test("returns undefined when no draft is filled", () => {
|
|
114
|
+
expect(
|
|
115
|
+
buildSecretOverrides({ secretEnv, drafts: {} }),
|
|
116
|
+
).toBeUndefined();
|
|
117
|
+
expect(
|
|
118
|
+
buildSecretOverrides({ secretEnv, drafts: { jira_token: "" } }),
|
|
119
|
+
).toBeUndefined();
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test("keeps only filled drafts for referenced names", () => {
|
|
123
|
+
expect(
|
|
124
|
+
buildSecretOverrides({
|
|
125
|
+
secretEnv,
|
|
126
|
+
drafts: { jira_token: "abc", db_pass: "" },
|
|
127
|
+
}),
|
|
128
|
+
).toEqual({ jira_token: "abc" });
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test("drops drafts for names not referenced by the mapping", () => {
|
|
132
|
+
expect(
|
|
133
|
+
buildSecretOverrides({
|
|
134
|
+
secretEnv,
|
|
135
|
+
drafts: { jira_token: "abc", stale: "leak" },
|
|
136
|
+
}),
|
|
137
|
+
).toEqual({ jira_token: "abc" });
|
|
138
|
+
});
|
|
139
|
+
});
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { extractErrorMessage } from "@checkstack/common";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Pure logic for the script-test panel, extracted so it can be unit-tested
|
|
5
|
+
* without rendering Monaco. The React component in `ScriptTestPanel.tsx`
|
|
6
|
+
* consumes these.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** Result of a single in-UI script test run (UI-side shape). */
|
|
10
|
+
export interface ScriptTestPanelResult {
|
|
11
|
+
result?: unknown;
|
|
12
|
+
stdout: string;
|
|
13
|
+
stderr: string;
|
|
14
|
+
exitCode?: number;
|
|
15
|
+
durationMs: number;
|
|
16
|
+
timedOut: boolean;
|
|
17
|
+
error?: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** A run is a failure if it errored or timed out. */
|
|
21
|
+
export function isFailedResult(result: ScriptTestPanelResult): boolean {
|
|
22
|
+
return result.error !== undefined || result.timedOut;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Pretty-print a script's return value for display. */
|
|
26
|
+
export function formatReturnValue(value: unknown): string {
|
|
27
|
+
if (value === undefined) return "undefined";
|
|
28
|
+
try {
|
|
29
|
+
return JSON.stringify(value, null, 2);
|
|
30
|
+
} catch {
|
|
31
|
+
return String(value);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* True when a result has nothing to show in the detail body (no error,
|
|
37
|
+
* no return value, no stdout/stderr) - the panel shows "No output." then.
|
|
38
|
+
*/
|
|
39
|
+
export function hasNoOutput(result: ScriptTestPanelResult): boolean {
|
|
40
|
+
return (
|
|
41
|
+
result.error === undefined &&
|
|
42
|
+
result.result === undefined &&
|
|
43
|
+
result.stdout.length === 0 &&
|
|
44
|
+
result.stderr.length === 0
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Validate the editable sample-context JSON. Returns the parse error
|
|
50
|
+
* message, or `null` when the value is empty or valid JSON.
|
|
51
|
+
*/
|
|
52
|
+
export function validateSampleContextJson(value: string): string | null {
|
|
53
|
+
if (value.trim().length === 0) return null;
|
|
54
|
+
try {
|
|
55
|
+
JSON.parse(value);
|
|
56
|
+
return null;
|
|
57
|
+
} catch (error) {
|
|
58
|
+
return extractErrorMessage(error, "Invalid JSON");
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Build the failure result the panel shows when `onRun` rejects. */
|
|
63
|
+
export function rejectionResult(error: unknown): ScriptTestPanelResult {
|
|
64
|
+
return {
|
|
65
|
+
stdout: "",
|
|
66
|
+
stderr: "",
|
|
67
|
+
durationMs: 0,
|
|
68
|
+
timedOut: false,
|
|
69
|
+
error: extractErrorMessage(error),
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// ─── Secret-env test overrides ──────────────────────────────────────────────
|
|
74
|
+
|
|
75
|
+
const SECRET_TEMPLATE_RE = /\$\{\{\s*secrets\.([a-zA-Z0-9_-]+)\s*\}\}/g;
|
|
76
|
+
// A mapping value may legacy-carry a bare secret name instead of a template.
|
|
77
|
+
const BARE_SECRET_NAME_RE = /^\s*([a-zA-Z][a-zA-Z0-9_-]*)\s*$/;
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Derive the set of distinct secret NAMES referenced by a `secretEnv`
|
|
81
|
+
* mapping (`{ ENV_NAME: "${{ secrets.NAME }}" }`). Tolerates a legacy bare
|
|
82
|
+
* secret name as a value too. Order-stable (first-seen) so the override UI
|
|
83
|
+
* renders deterministically. Returns `[]` when the mapping is absent/empty.
|
|
84
|
+
*/
|
|
85
|
+
export function distinctSecretNames(
|
|
86
|
+
secretEnv: Record<string, string> | undefined,
|
|
87
|
+
): string[] {
|
|
88
|
+
if (!secretEnv) return [];
|
|
89
|
+
const seen = new Set<string>();
|
|
90
|
+
const out: string[] = [];
|
|
91
|
+
const add = (name: string) => {
|
|
92
|
+
if (!seen.has(name)) {
|
|
93
|
+
seen.add(name);
|
|
94
|
+
out.push(name);
|
|
95
|
+
}
|
|
96
|
+
};
|
|
97
|
+
for (const value of Object.values(secretEnv)) {
|
|
98
|
+
SECRET_TEMPLATE_RE.lastIndex = 0;
|
|
99
|
+
let matched = false;
|
|
100
|
+
let match: RegExpExecArray | null;
|
|
101
|
+
while ((match = SECRET_TEMPLATE_RE.exec(value)) !== null) {
|
|
102
|
+
matched = true;
|
|
103
|
+
add(match[1]);
|
|
104
|
+
}
|
|
105
|
+
if (!matched) {
|
|
106
|
+
const bare = BARE_SECRET_NAME_RE.exec(value);
|
|
107
|
+
if (bare) add(bare[1]);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return out;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Build the `secretOverrides` payload sent to the test endpoint from the
|
|
115
|
+
* user's per-secret-NAME draft inputs. Drops blank entries (an empty
|
|
116
|
+
* override means "use the placeholder") and keeps only names actually
|
|
117
|
+
* referenced by the mapping, so a stale draft can't inject an unreferenced
|
|
118
|
+
* value. Returns `undefined` when nothing is overridden so the wire stays
|
|
119
|
+
* clean.
|
|
120
|
+
*/
|
|
121
|
+
export function buildSecretOverrides({
|
|
122
|
+
secretEnv,
|
|
123
|
+
drafts,
|
|
124
|
+
}: {
|
|
125
|
+
secretEnv: Record<string, string> | undefined;
|
|
126
|
+
drafts: Record<string, string>;
|
|
127
|
+
}): Record<string, string> | undefined {
|
|
128
|
+
const names = distinctSecretNames(secretEnv);
|
|
129
|
+
const out: Record<string, string> = {};
|
|
130
|
+
for (const name of names) {
|
|
131
|
+
const value = drafts[name];
|
|
132
|
+
if (value !== undefined && value.length > 0) {
|
|
133
|
+
out[name] = value;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
return Object.keys(out).length > 0 ? out : undefined;
|
|
137
|
+
}
|