@checkstack/ui 1.12.0 → 1.13.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/CHANGELOG.md +145 -0
- package/package.json +20 -15
- package/src/components/Accordion.tsx +17 -9
- package/src/components/ActionCard.tsx +4 -4
- package/src/components/BrandIcon.tsx +57 -0
- package/src/components/CodeEditor/CodeEditor.tsx +71 -7
- package/src/components/CodeEditor/TypefoxEditor.tsx +266 -53
- package/src/components/CodeEditor/editorTheme.test.ts +41 -0
- package/src/components/CodeEditor/editorTheme.ts +26 -0
- package/src/components/CodeEditor/index.ts +3 -1
- package/src/components/CodeEditor/monacoGuard.ts +76 -0
- package/src/components/CodeEditor/monacoTsService.ts +5 -37
- package/src/components/CodeEditor/scriptContext.test.ts +15 -7
- package/src/components/CodeEditor/scriptContext.ts +12 -18
- package/src/components/CodeEditor/types.ts +20 -0
- package/src/components/CodeEditor/validateScripts.ts +53 -13
- package/src/components/CodeEditor/vscodeServicesSignal.ts +72 -0
- package/src/components/ConfirmationModal.tsx +7 -1
- package/src/components/DynamicForm/DynamicForm.tsx +101 -53
- package/src/components/DynamicForm/DynamicOptionsField.tsx +19 -14
- package/src/components/DynamicForm/FormField.tsx +84 -24
- package/src/components/DynamicForm/MultiTypeEditorField.tsx +11 -0
- package/src/components/DynamicForm/index.ts +14 -0
- package/src/components/DynamicForm/types.ts +63 -1
- package/src/components/DynamicForm/utils.test.ts +38 -0
- package/src/components/DynamicForm/utils.ts +22 -0
- package/src/components/DynamicForm/validation.logic.test.ts +255 -0
- package/src/components/DynamicForm/validation.logic.ts +210 -0
- package/src/components/DynamicIcon.tsx +39 -17
- package/src/components/Markdown.tsx +68 -2
- package/src/components/Spinner.tsx +56 -0
- package/src/components/StatusBadge.tsx +78 -0
- package/src/components/StrategyConfigCard.tsx +3 -3
- package/src/components/Tabs.tsx +7 -1
- package/src/components/UserMenu.logic.test.ts +37 -0
- package/src/components/UserMenu.logic.ts +30 -0
- package/src/components/UserMenu.tsx +40 -12
- package/src/components/iconRegistry.tsx +27 -0
- package/src/index.ts +3 -0
- package/stories/Introduction.mdx +1 -1
- package/stories/Markdown.stories.tsx +56 -0
- package/stories/Spinner.stories.tsx +90 -0
- package/tsconfig.json +3 -0
|
@@ -5,10 +5,10 @@ import { EmptyState } from "../../index";
|
|
|
5
5
|
import type { DynamicFormProps } from "./types";
|
|
6
6
|
import {
|
|
7
7
|
extractDefaults,
|
|
8
|
-
isValueEmpty,
|
|
9
8
|
isFieldHiddenByCondition,
|
|
10
9
|
findSecretEnvSibling,
|
|
11
10
|
} from "./utils";
|
|
11
|
+
import { deriveClientFieldErrors } from "./validation.logic";
|
|
12
12
|
import { FormField } from "./FormField";
|
|
13
13
|
|
|
14
14
|
/**
|
|
@@ -31,11 +31,22 @@ export const DynamicForm: React.FC<DynamicFormProps> = ({
|
|
|
31
31
|
secretNames,
|
|
32
32
|
acquireTypes,
|
|
33
33
|
acquireResetKey,
|
|
34
|
+
sdkTypes,
|
|
35
|
+
sdkTypesResetKey,
|
|
34
36
|
importablePackages,
|
|
37
|
+
templatePreviewContext,
|
|
38
|
+
showInlineErrors = false,
|
|
39
|
+
fieldErrors,
|
|
40
|
+
keepExistingSecretFields,
|
|
35
41
|
}) => {
|
|
36
42
|
// Track previous validity to avoid redundant callbacks
|
|
37
43
|
const prevValidRef = React.useRef<boolean | undefined>(undefined);
|
|
38
44
|
|
|
45
|
+
// Top-level field keys the user has interacted with (touched + blurred).
|
|
46
|
+
// Client-side inline errors only show for touched fields so the form does
|
|
47
|
+
// not nag while the user is still filling it in for the first time.
|
|
48
|
+
const [touched, setTouched] = React.useState<Record<string, boolean>>({});
|
|
49
|
+
|
|
39
50
|
// Initialize form with default values from schema
|
|
40
51
|
React.useEffect(() => {
|
|
41
52
|
if (!schema || !schema.properties) return;
|
|
@@ -50,40 +61,24 @@ export const DynamicForm: React.FC<DynamicFormProps> = ({
|
|
|
50
61
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- Intentional: runs only on schema change. Including onChange would re-fire on parent re-renders; including value would cause an infinite loop since this effect calls onChange(merged)
|
|
51
62
|
}, [schema]);
|
|
52
63
|
|
|
53
|
-
//
|
|
64
|
+
// Single source of truth for client-side validation: the same per-field
|
|
65
|
+
// error map drives both the inline messages and the validity boolean
|
|
66
|
+
// (valid iff the map is empty), so what disables the submit button and
|
|
67
|
+
// what the user sees can never disagree. Honors keep-existing secrets.
|
|
68
|
+
const clientErrors = React.useMemo(() => {
|
|
69
|
+
if (!schema || !schema.properties) return {};
|
|
70
|
+
return deriveClientFieldErrors({ schema, value, keepExistingSecretFields });
|
|
71
|
+
}, [schema, value, keepExistingSecretFields]);
|
|
72
|
+
|
|
73
|
+
// Report validity changes.
|
|
54
74
|
React.useEffect(() => {
|
|
55
75
|
if (!onValidChange || !schema || !schema.properties) return;
|
|
56
|
-
|
|
57
|
-
// Check all required fields (including hidden ones like connectionId)
|
|
58
|
-
const requiredKeys = schema.required ?? [];
|
|
59
|
-
let isValid = true;
|
|
60
|
-
|
|
61
|
-
for (const key of requiredKeys) {
|
|
62
|
-
const propSchema = schema.properties[key];
|
|
63
|
-
if (!propSchema) continue;
|
|
64
|
-
|
|
65
|
-
// Skip hidden fields - they are auto-populated
|
|
66
|
-
if (propSchema["x-hidden"]) continue;
|
|
67
|
-
|
|
68
|
-
// Skip conditionally hidden fields - they are not visible
|
|
69
|
-
if (
|
|
70
|
-
propSchema["x-hidden-when"] &&
|
|
71
|
-
isFieldHiddenByCondition(propSchema["x-hidden-when"], value)
|
|
72
|
-
)
|
|
73
|
-
continue;
|
|
74
|
-
|
|
75
|
-
if (isValueEmpty(value[key], propSchema)) {
|
|
76
|
-
isValid = false;
|
|
77
|
-
break;
|
|
78
|
-
}
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
// Only call onValidChange if validity actually changed
|
|
76
|
+
const isValid = Object.keys(clientErrors).length === 0;
|
|
82
77
|
if (prevValidRef.current !== isValid) {
|
|
83
78
|
prevValidRef.current = isValid;
|
|
84
79
|
onValidChange(isValid);
|
|
85
80
|
}
|
|
86
|
-
}, [schema,
|
|
81
|
+
}, [schema, clientErrors, onValidChange]);
|
|
87
82
|
|
|
88
83
|
if (
|
|
89
84
|
!schema ||
|
|
@@ -122,33 +117,86 @@ export const DynamicForm: React.FC<DynamicFormProps> = ({
|
|
|
122
117
|
const isRequired = schema.required?.includes(key);
|
|
123
118
|
const label = key.charAt(0).toUpperCase() + key.slice(1);
|
|
124
119
|
|
|
120
|
+
// Resolve the inline message for this field. Server-supplied
|
|
121
|
+
// `fieldErrors` (keyed by exact key or a nested `key.*` path) take
|
|
122
|
+
// precedence and show whenever present; client errors only show
|
|
123
|
+
// when inline errors are enabled AND the field has been touched.
|
|
124
|
+
// Either way the message renders below the field without changing
|
|
125
|
+
// the field's own markup.
|
|
126
|
+
const clientError =
|
|
127
|
+
showInlineErrors && touched[key] ? clientErrors[key] : undefined;
|
|
128
|
+
const fieldError = resolveFieldError({
|
|
129
|
+
key,
|
|
130
|
+
fieldErrors,
|
|
131
|
+
clientError,
|
|
132
|
+
});
|
|
133
|
+
|
|
125
134
|
return (
|
|
126
|
-
<
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
135
|
+
<div key={key} className="space-y-1.5">
|
|
136
|
+
<FormField
|
|
137
|
+
// Prefix with 'field-' to prevent DOM clobbering when field
|
|
138
|
+
// names match native DOM properties (e.g. nodeName, tagName)
|
|
139
|
+
id={`field-${key}`}
|
|
140
|
+
label={label}
|
|
141
|
+
propSchema={propSchema}
|
|
142
|
+
value={value[key]}
|
|
143
|
+
isRequired={isRequired}
|
|
144
|
+
formValues={value}
|
|
145
|
+
optionsResolvers={optionsResolvers}
|
|
146
|
+
templateProperties={templateProperties}
|
|
147
|
+
templateCompletionProvider={templateCompletionProvider}
|
|
148
|
+
typeDefinitions={typeDefinitions}
|
|
149
|
+
shellEnvVars={shellEnvVars}
|
|
150
|
+
starterTemplates={starterTemplates}
|
|
151
|
+
scriptTestRenderer={scriptTestRenderer}
|
|
152
|
+
secretNames={secretNames}
|
|
153
|
+
acquireTypes={acquireTypes}
|
|
154
|
+
acquireResetKey={acquireResetKey}
|
|
155
|
+
sdkTypes={sdkTypes}
|
|
156
|
+
sdkTypesResetKey={sdkTypesResetKey}
|
|
157
|
+
importablePackages={importablePackages}
|
|
158
|
+
templatePreviewContext={templatePreviewContext}
|
|
159
|
+
siblingSecretEnv={rootSecretEnv}
|
|
160
|
+
onChange={(val) => {
|
|
161
|
+
// First interaction marks the field touched so its inline
|
|
162
|
+
// required error can appear (covers touched-then-blanked).
|
|
163
|
+
if (showInlineErrors && !touched[key]) {
|
|
164
|
+
setTouched((prev) => ({ ...prev, [key]: true }));
|
|
165
|
+
}
|
|
166
|
+
onChange({ ...value, [key]: val });
|
|
167
|
+
}}
|
|
168
|
+
/>
|
|
169
|
+
{fieldError && (
|
|
170
|
+
<p className="text-xs text-destructive">{fieldError}</p>
|
|
171
|
+
)}
|
|
172
|
+
</div>
|
|
150
173
|
);
|
|
151
174
|
})}
|
|
152
175
|
</div>
|
|
153
176
|
);
|
|
154
177
|
};
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Pick the inline error message to render under a top-level field. Server
|
|
181
|
+
* errors (`fieldErrors`) win over the client error and may be keyed either
|
|
182
|
+
* by the exact field key or by a nested path (`key.child`); the first nested
|
|
183
|
+
* match is surfaced so a sub-field problem still flags its parent field.
|
|
184
|
+
*/
|
|
185
|
+
function resolveFieldError({
|
|
186
|
+
key,
|
|
187
|
+
fieldErrors,
|
|
188
|
+
clientError,
|
|
189
|
+
}: {
|
|
190
|
+
key: string;
|
|
191
|
+
fieldErrors: Record<string, string> | undefined;
|
|
192
|
+
clientError: string | undefined;
|
|
193
|
+
}): string | undefined {
|
|
194
|
+
if (fieldErrors) {
|
|
195
|
+
if (fieldErrors[key] !== undefined) return fieldErrors[key];
|
|
196
|
+
const nestedKey = Object.keys(fieldErrors).find((path) =>
|
|
197
|
+
path.startsWith(`${key}.`),
|
|
198
|
+
);
|
|
199
|
+
if (nestedKey !== undefined) return fieldErrors[nestedKey];
|
|
200
|
+
}
|
|
201
|
+
return clientError;
|
|
202
|
+
}
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import React from "react";
|
|
2
|
-
import {
|
|
3
|
-
import { cn } from "../../utils";
|
|
2
|
+
import { ChevronDown } from "lucide-react";
|
|
4
3
|
|
|
5
4
|
import {
|
|
6
5
|
Input,
|
|
@@ -10,7 +9,7 @@ import {
|
|
|
10
9
|
SelectItem,
|
|
11
10
|
SelectTrigger,
|
|
12
11
|
SelectValue,
|
|
13
|
-
|
|
12
|
+
Spinner,
|
|
14
13
|
} from "../../index";
|
|
15
14
|
|
|
16
15
|
import type { DynamicOptionsFieldProps, ResolverOption } from "./types";
|
|
@@ -35,7 +34,6 @@ export const DynamicOptionsField: React.FC<DynamicOptionsFieldProps> = ({
|
|
|
35
34
|
optionsResolvers,
|
|
36
35
|
onChange,
|
|
37
36
|
}) => {
|
|
38
|
-
const { isLowPower } = usePerformance();
|
|
39
37
|
const [options, setOptions] = React.useState<ResolverOption[]>([]);
|
|
40
38
|
const [loading, setLoading] = React.useState(true);
|
|
41
39
|
const [error, setError] = React.useState<string | undefined>();
|
|
@@ -46,6 +44,15 @@ export const DynamicOptionsField: React.FC<DynamicOptionsFieldProps> = ({
|
|
|
46
44
|
const formValuesRef = React.useRef(formValues);
|
|
47
45
|
formValuesRef.current = formValues;
|
|
48
46
|
|
|
47
|
+
// Ref the resolvers too. A parent re-render (e.g. typing in ANOTHER field of
|
|
48
|
+
// the same form) can hand down a new `optionsResolvers` object identity even
|
|
49
|
+
// though the resolver for THIS field is unchanged. Reading it from a ref keeps
|
|
50
|
+
// it out of the fetch effect's dependencies, so the field only re-fetches when
|
|
51
|
+
// its resolver NAME or its declared `x-depends-on` values change - not on
|
|
52
|
+
// every unrelated keystroke (which made the picker flash + re-fetch).
|
|
53
|
+
const optionsResolversRef = React.useRef(optionsResolvers);
|
|
54
|
+
optionsResolversRef.current = optionsResolvers;
|
|
55
|
+
|
|
49
56
|
// Build dependency values string for useEffect dependency tracking
|
|
50
57
|
// Only includes the specific fields this resolver depends on
|
|
51
58
|
const dependencyValues = React.useMemo(() => {
|
|
@@ -54,7 +61,7 @@ export const DynamicOptionsField: React.FC<DynamicOptionsFieldProps> = ({
|
|
|
54
61
|
}, [dependsOn, formValues]);
|
|
55
62
|
|
|
56
63
|
React.useEffect(() => {
|
|
57
|
-
const resolver =
|
|
64
|
+
const resolver = optionsResolversRef.current[resolverName];
|
|
58
65
|
if (!resolver) {
|
|
59
66
|
setError(`Resolver "${resolverName}" not found`);
|
|
60
67
|
setLoading(false);
|
|
@@ -65,7 +72,8 @@ export const DynamicOptionsField: React.FC<DynamicOptionsFieldProps> = ({
|
|
|
65
72
|
setLoading(true);
|
|
66
73
|
setError(undefined);
|
|
67
74
|
|
|
68
|
-
// Use
|
|
75
|
+
// Use refs to get the current resolvers + form values without adding them
|
|
76
|
+
// to the dependencies (see the refs above).
|
|
69
77
|
resolver(formValuesRef.current)
|
|
70
78
|
.then((result) => {
|
|
71
79
|
if (!cancelled) {
|
|
@@ -83,8 +91,10 @@ export const DynamicOptionsField: React.FC<DynamicOptionsFieldProps> = ({
|
|
|
83
91
|
return () => {
|
|
84
92
|
cancelled = true;
|
|
85
93
|
};
|
|
86
|
-
// Only re-fetch when resolver
|
|
87
|
-
|
|
94
|
+
// Only re-fetch when the resolver NAME or this field's declared
|
|
95
|
+
// `x-depends-on` values change - NOT when an unrelated field re-renders the
|
|
96
|
+
// form (the resolvers object identity is read via ref above).
|
|
97
|
+
}, [resolverName, dependencyValues]);
|
|
88
98
|
|
|
89
99
|
// Filter options based on search query
|
|
90
100
|
const filteredOptions = React.useMemo(() => {
|
|
@@ -200,12 +210,7 @@ export const DynamicOptionsField: React.FC<DynamicOptionsFieldProps> = ({
|
|
|
200
210
|
<div className="relative">
|
|
201
211
|
{loading ? (
|
|
202
212
|
<div className="flex items-center gap-2 h-10 px-3 border rounded-md bg-muted/50">
|
|
203
|
-
<
|
|
204
|
-
className={cn(
|
|
205
|
-
"h-4 w-4 text-muted-foreground",
|
|
206
|
-
!isLowPower && "animate-spin",
|
|
207
|
-
)}
|
|
208
|
-
/>
|
|
213
|
+
<Spinner size="sm" className="text-muted-foreground" />
|
|
209
214
|
<span className="text-sm text-muted-foreground">
|
|
210
215
|
Loading options...
|
|
211
216
|
</span>
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { useEffect, useState } from "react";
|
|
2
2
|
import { Plus, Trash2 } from "lucide-react";
|
|
3
|
+
import { renderTemplatePreview } from "@checkstack/template-engine";
|
|
3
4
|
|
|
4
5
|
import {
|
|
5
6
|
Input,
|
|
@@ -23,6 +24,7 @@ import {
|
|
|
23
24
|
getCleanDescription,
|
|
24
25
|
NONE_SENTINEL,
|
|
25
26
|
findSecretEnvSibling,
|
|
27
|
+
nestedChildrenRequired,
|
|
26
28
|
} from "./utils";
|
|
27
29
|
import { DynamicOptionsField } from "./DynamicOptionsField";
|
|
28
30
|
import { JsonField } from "./JsonField";
|
|
@@ -49,7 +51,10 @@ export const FormField: React.FC<FormFieldProps> = ({
|
|
|
49
51
|
secretNames,
|
|
50
52
|
acquireTypes,
|
|
51
53
|
acquireResetKey,
|
|
54
|
+
sdkTypes,
|
|
55
|
+
sdkTypesResetKey,
|
|
52
56
|
importablePackages,
|
|
57
|
+
templatePreviewContext,
|
|
53
58
|
siblingSecretEnv,
|
|
54
59
|
onChange,
|
|
55
60
|
}) => {
|
|
@@ -167,29 +172,39 @@ export const FormField: React.FC<FormFieldProps> = ({
|
|
|
167
172
|
const editorTypes = propSchema["x-editor-types"];
|
|
168
173
|
if (editorTypes && editorTypes.length > 0) {
|
|
169
174
|
return (
|
|
170
|
-
<
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
175
|
+
<div className="space-y-2">
|
|
176
|
+
<MultiTypeEditorField
|
|
177
|
+
id={id}
|
|
178
|
+
label={label}
|
|
179
|
+
description={cleanDesc}
|
|
180
|
+
value={value as string | undefined}
|
|
181
|
+
isRequired={isRequired}
|
|
182
|
+
editorTypes={editorTypes}
|
|
183
|
+
templateProperties={templateProperties}
|
|
184
|
+
typeDefinitions={typeDefinitions}
|
|
185
|
+
shellEnvVars={shellEnvVars}
|
|
186
|
+
starterTemplates={starterTemplates}
|
|
187
|
+
scriptTestRenderer={
|
|
188
|
+
propSchema["x-script-testable"] === true
|
|
189
|
+
? scriptTestRenderer
|
|
190
|
+
: undefined
|
|
191
|
+
}
|
|
192
|
+
acquireTypes={acquireTypes}
|
|
193
|
+
acquireResetKey={acquireResetKey}
|
|
194
|
+
sdkTypes={sdkTypes}
|
|
195
|
+
sdkTypesResetKey={sdkTypesResetKey}
|
|
196
|
+
importablePackages={importablePackages}
|
|
197
|
+
fieldId={id}
|
|
198
|
+
siblingSecretEnv={siblingSecretEnv}
|
|
199
|
+
onChange={onChange as (val: string | undefined) => void}
|
|
200
|
+
/>
|
|
201
|
+
{propSchema["x-templatable"] && templatePreviewContext && (
|
|
202
|
+
<TemplatePreviewLine
|
|
203
|
+
value={(value as string) || ""}
|
|
204
|
+
context={templatePreviewContext}
|
|
205
|
+
/>
|
|
206
|
+
)}
|
|
207
|
+
</div>
|
|
193
208
|
);
|
|
194
209
|
}
|
|
195
210
|
|
|
@@ -319,6 +334,12 @@ export const FormField: React.FC<FormFieldProps> = ({
|
|
|
319
334
|
placeholder={placeholder}
|
|
320
335
|
/>
|
|
321
336
|
)}
|
|
337
|
+
{propSchema["x-templatable"] && templatePreviewContext && (
|
|
338
|
+
<TemplatePreviewLine
|
|
339
|
+
value={(value as string) || ""}
|
|
340
|
+
context={templatePreviewContext}
|
|
341
|
+
/>
|
|
342
|
+
)}
|
|
322
343
|
</div>
|
|
323
344
|
);
|
|
324
345
|
}
|
|
@@ -440,6 +461,14 @@ export const FormField: React.FC<FormFieldProps> = ({
|
|
|
440
461
|
properties: propSchema.properties,
|
|
441
462
|
values: value as Record<string, unknown> | undefined,
|
|
442
463
|
});
|
|
464
|
+
// An OPTIONAL nested object (e.g. an opt-in spend cap) only marks its
|
|
465
|
+
// schema-required children with `*` once the operator starts providing the
|
|
466
|
+
// object; while empty, supplying it is optional. A required object always
|
|
467
|
+
// marks them. (See nestedChildrenRequired.)
|
|
468
|
+
const childrenRequired = nestedChildrenRequired({
|
|
469
|
+
objectRequired: isRequired ?? false,
|
|
470
|
+
objectValue: value,
|
|
471
|
+
});
|
|
443
472
|
return (
|
|
444
473
|
<div className="space-y-4 p-4 border rounded-lg bg-muted/30">
|
|
445
474
|
<p className="text-sm font-semibold">{label}</p>
|
|
@@ -450,7 +479,7 @@ export const FormField: React.FC<FormFieldProps> = ({
|
|
|
450
479
|
label={key.charAt(0).toUpperCase() + key.slice(1)}
|
|
451
480
|
propSchema={subSchema}
|
|
452
481
|
value={(value as Record<string, unknown>)?.[key]}
|
|
453
|
-
isRequired={propSchema.required?.includes(key)}
|
|
482
|
+
isRequired={childrenRequired && (propSchema.required?.includes(key) ?? false)}
|
|
454
483
|
formValues={formValues}
|
|
455
484
|
optionsResolvers={optionsResolvers}
|
|
456
485
|
templateProperties={templateProperties}
|
|
@@ -462,7 +491,10 @@ export const FormField: React.FC<FormFieldProps> = ({
|
|
|
462
491
|
secretNames={secretNames}
|
|
463
492
|
acquireTypes={acquireTypes}
|
|
464
493
|
acquireResetKey={acquireResetKey}
|
|
494
|
+
sdkTypes={sdkTypes}
|
|
495
|
+
sdkTypesResetKey={sdkTypesResetKey}
|
|
465
496
|
importablePackages={importablePackages}
|
|
497
|
+
templatePreviewContext={templatePreviewContext}
|
|
466
498
|
siblingSecretEnv={nestedSecretEnv}
|
|
467
499
|
onChange={(val) =>
|
|
468
500
|
onChange({ ...(value as Record<string, unknown>), [key]: val })
|
|
@@ -580,7 +612,10 @@ export const FormField: React.FC<FormFieldProps> = ({
|
|
|
580
612
|
secretNames={secretNames}
|
|
581
613
|
acquireTypes={acquireTypes}
|
|
582
614
|
acquireResetKey={acquireResetKey}
|
|
615
|
+
sdkTypes={sdkTypes}
|
|
616
|
+
sdkTypesResetKey={sdkTypesResetKey}
|
|
583
617
|
importablePackages={importablePackages}
|
|
618
|
+
templatePreviewContext={templatePreviewContext}
|
|
584
619
|
onChange={(val) => {
|
|
585
620
|
const next = [...(items as unknown[])];
|
|
586
621
|
next[index] = val;
|
|
@@ -723,7 +758,10 @@ export const FormField: React.FC<FormFieldProps> = ({
|
|
|
723
758
|
secretNames={secretNames}
|
|
724
759
|
acquireTypes={acquireTypes}
|
|
725
760
|
acquireResetKey={acquireResetKey}
|
|
761
|
+
sdkTypes={sdkTypes}
|
|
762
|
+
sdkTypesResetKey={sdkTypesResetKey}
|
|
726
763
|
importablePackages={importablePackages}
|
|
764
|
+
templatePreviewContext={templatePreviewContext}
|
|
727
765
|
siblingSecretEnv={variantSecretEnv}
|
|
728
766
|
onChange={(val) => onChange({ ...currentValue, [key]: val })}
|
|
729
767
|
/>
|
|
@@ -735,6 +773,28 @@ export const FormField: React.FC<FormFieldProps> = ({
|
|
|
735
773
|
return <></>;
|
|
736
774
|
};
|
|
737
775
|
|
|
776
|
+
/**
|
|
777
|
+
* Inline preview of a templatable field's rendered output against a sample
|
|
778
|
+
* context. Shown below `x-templatable` string fields when the owning form
|
|
779
|
+
* supplies a `templatePreviewContext`. Pure render (no DOM/Monaco), so it
|
|
780
|
+
* matches the run-time `x-templatable` pass exactly.
|
|
781
|
+
*/
|
|
782
|
+
const TemplatePreviewLine: React.FC<{
|
|
783
|
+
value: string;
|
|
784
|
+
context: Record<string, unknown>;
|
|
785
|
+
}> = ({ value, context }) => {
|
|
786
|
+
if (!value || !value.includes("{{")) return null;
|
|
787
|
+
const rendered = renderTemplatePreview({ value, context });
|
|
788
|
+
return (
|
|
789
|
+
<p className="text-xs text-muted-foreground">
|
|
790
|
+
Preview:{" "}
|
|
791
|
+
<span className="font-mono break-all text-foreground">
|
|
792
|
+
{rendered || "(empty)"}
|
|
793
|
+
</span>
|
|
794
|
+
</p>
|
|
795
|
+
);
|
|
796
|
+
};
|
|
797
|
+
|
|
738
798
|
/**
|
|
739
799
|
* Shared visibility toggle button for secret fields.
|
|
740
800
|
*/
|
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
CodeEditor,
|
|
6
6
|
type TemplateProperty,
|
|
7
7
|
type AcquireTypes,
|
|
8
|
+
type AcquiredTypeFile,
|
|
8
9
|
} from "../CodeEditor";
|
|
9
10
|
import {
|
|
10
11
|
Select,
|
|
@@ -76,6 +77,10 @@ export interface MultiTypeEditorFieldProps {
|
|
|
76
77
|
acquireTypes?: AcquireTypes;
|
|
77
78
|
/** Install identity (lockfile hash); resets acquired types on a new install. */
|
|
78
79
|
acquireResetKey?: string;
|
|
80
|
+
/** The running release's `@checkstack/sdk` editor bundle (helpers + client). */
|
|
81
|
+
sdkTypes?: ReadonlyArray<AcquiredTypeFile>;
|
|
82
|
+
/** Release version; resets the mounted SDK libs on a deployment upgrade. */
|
|
83
|
+
sdkTypesResetKey?: string;
|
|
79
84
|
/**
|
|
80
85
|
* Importable installed package names (`@types/*`-free), forwarded to the
|
|
81
86
|
* TS/JS `CodeEditor` so the import specifier itself autocompletes.
|
|
@@ -113,6 +118,8 @@ export const MultiTypeEditorField: React.FC<MultiTypeEditorFieldProps> = ({
|
|
|
113
118
|
scriptTestRenderer,
|
|
114
119
|
acquireTypes,
|
|
115
120
|
acquireResetKey,
|
|
121
|
+
sdkTypes,
|
|
122
|
+
sdkTypesResetKey,
|
|
116
123
|
importablePackages,
|
|
117
124
|
fieldId,
|
|
118
125
|
siblingSecretEnv,
|
|
@@ -396,6 +403,8 @@ export const MultiTypeEditorField: React.FC<MultiTypeEditorFieldProps> = ({
|
|
|
396
403
|
typeDefinitions={typeDefinitions}
|
|
397
404
|
acquireTypes={acquireTypes}
|
|
398
405
|
acquireResetKey={acquireResetKey}
|
|
406
|
+
sdkTypes={sdkTypes}
|
|
407
|
+
sdkTypesResetKey={sdkTypesResetKey}
|
|
399
408
|
importablePackages={importablePackages}
|
|
400
409
|
/>
|
|
401
410
|
)}
|
|
@@ -410,6 +419,8 @@ export const MultiTypeEditorField: React.FC<MultiTypeEditorFieldProps> = ({
|
|
|
410
419
|
typeDefinitions={typeDefinitions}
|
|
411
420
|
acquireTypes={acquireTypes}
|
|
412
421
|
acquireResetKey={acquireResetKey}
|
|
422
|
+
sdkTypes={sdkTypes}
|
|
423
|
+
sdkTypesResetKey={sdkTypesResetKey}
|
|
413
424
|
importablePackages={importablePackages}
|
|
414
425
|
/>
|
|
415
426
|
)}
|
|
@@ -30,3 +30,17 @@ export {
|
|
|
30
30
|
EDITOR_TYPE_LABELS,
|
|
31
31
|
findSecretEnvSibling,
|
|
32
32
|
} from "./utils";
|
|
33
|
+
|
|
34
|
+
// Validation logic (pure, DOM-free) for inline field errors and the
|
|
35
|
+
// keep-existing-secret rule. Consumers map server zod issues to fields and
|
|
36
|
+
// strip blank keep-existing secrets before submit.
|
|
37
|
+
export {
|
|
38
|
+
deriveClientFieldErrors,
|
|
39
|
+
deriveServerFieldErrors,
|
|
40
|
+
parseServerValidationData,
|
|
41
|
+
omitKeepExistingSecrets,
|
|
42
|
+
listSecretFieldKeys,
|
|
43
|
+
serverValidationDataSchema,
|
|
44
|
+
type FieldErrorMap,
|
|
45
|
+
type ServerValidationData,
|
|
46
|
+
} from "./validation.logic";
|
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
import type React from "react";
|
|
2
|
-
import type {
|
|
2
|
+
import type {
|
|
3
|
+
TemplateProperty,
|
|
4
|
+
ShellEnvVar,
|
|
5
|
+
AcquireTypes,
|
|
6
|
+
AcquiredTypeFile,
|
|
7
|
+
} from "../CodeEditor";
|
|
3
8
|
import type { TemplateCompletionProvider } from "../TemplateValueInput";
|
|
4
9
|
import type { EditorType } from "@checkstack/common";
|
|
5
10
|
|
|
@@ -12,6 +17,7 @@ import type {
|
|
|
12
17
|
JsonSchemaPropertyCore,
|
|
13
18
|
JsonSchemaBase,
|
|
14
19
|
} from "@checkstack/common";
|
|
20
|
+
import type { FieldErrorMap } from "./validation.logic";
|
|
15
21
|
|
|
16
22
|
/**
|
|
17
23
|
* JSON Schema property with DynamicForm-specific x-* extensions for config rendering.
|
|
@@ -30,8 +36,19 @@ export interface JsonSchemaProperty extends JsonSchemaPropertyCore<JsonSchemaPro
|
|
|
30
36
|
"x-duration"?: boolean; // Render a DurationInput (single-unit duration object)
|
|
31
37
|
"x-script-testable"?: boolean; // Field is an inline script that can be tested in-UI
|
|
32
38
|
"x-secret-env"?: boolean; // Record field is a secret -> env mapping (SecretEnvEditor)
|
|
39
|
+
"x-templatable"?: boolean; // String value is rendered through the template engine at run time
|
|
33
40
|
}
|
|
34
41
|
|
|
42
|
+
/**
|
|
43
|
+
* Sample context used to preview the rendered output of `x-templatable`
|
|
44
|
+
* string fields in the editor (e.g. `{ environment: { baseUrl: "..." } }`).
|
|
45
|
+
* When supplied to DynamicForm, a "Preview" line appears below each
|
|
46
|
+
* templatable field showing `renderTemplatePreview(value, context)` so authors
|
|
47
|
+
* see the resolved value while editing. Shares the run-time render semantics
|
|
48
|
+
* (see `@checkstack/template-engine`), so the preview never diverges.
|
|
49
|
+
*/
|
|
50
|
+
export type TemplatePreviewContext = Record<string, unknown>;
|
|
51
|
+
|
|
35
52
|
/**
|
|
36
53
|
* Renders the inline script-test UI beneath a testable script field. The
|
|
37
54
|
* owning feature page supplies this; it owns the RPC call + sample-context
|
|
@@ -152,12 +169,53 @@ export interface DynamicFormProps {
|
|
|
152
169
|
acquireTypes?: AcquireTypes;
|
|
153
170
|
/** Install identity (lockfile hash); resets acquired types on a new install. */
|
|
154
171
|
acquireResetKey?: string;
|
|
172
|
+
/**
|
|
173
|
+
* The running release's `@checkstack/sdk` editor bundle, forwarded to TS/JS
|
|
174
|
+
* editors so `import { defineHealthCheck } from "@checkstack/sdk/healthcheck"`
|
|
175
|
+
* resolves with real, version-matched types.
|
|
176
|
+
*/
|
|
177
|
+
sdkTypes?: ReadonlyArray<AcquiredTypeFile>;
|
|
178
|
+
/** Release version; resets the mounted SDK libs on a deployment upgrade. */
|
|
179
|
+
sdkTypesResetKey?: string;
|
|
155
180
|
/**
|
|
156
181
|
* Importable installed package names (already `@types/*`-free), forwarded to
|
|
157
182
|
* TS/JS editors so the import specifier itself autocompletes
|
|
158
183
|
* (`import {} from "lod"` -> `lodash`).
|
|
159
184
|
*/
|
|
160
185
|
importablePackages?: string[];
|
|
186
|
+
/**
|
|
187
|
+
* Optional sample context for previewing `x-templatable` fields. When
|
|
188
|
+
* supplied, a "Preview" line appears below each templatable string field
|
|
189
|
+
* showing the rendered output against this context (e.g. a sample
|
|
190
|
+
* environment's custom fields). Omit it and templatable fields render
|
|
191
|
+
* normally with no preview.
|
|
192
|
+
*/
|
|
193
|
+
templatePreviewContext?: TemplatePreviewContext;
|
|
194
|
+
/**
|
|
195
|
+
* Opt-in. When `true`, the form shows inline per-field validation messages
|
|
196
|
+
* (for empty required fields) REACTIVELY once a field has been touched and
|
|
197
|
+
* blurred, so the user can see WHY the submit button is disabled without
|
|
198
|
+
* the form nagging while they are still typing. Defaults to `false`, so
|
|
199
|
+
* every existing consumer is visually unchanged unless it opts in.
|
|
200
|
+
*/
|
|
201
|
+
showInlineErrors?: boolean;
|
|
202
|
+
/**
|
|
203
|
+
* Optional externally-supplied per-field error messages, keyed by field
|
|
204
|
+
* path (dot-joined for nested object fields, e.g. `spendCap.tokenBudget`).
|
|
205
|
+
* Use this to surface SERVER validation failures inline on the offending
|
|
206
|
+
* field. These render whenever present, independent of the touched-state
|
|
207
|
+
* gate. Omit it (the default) and no external errors show.
|
|
208
|
+
*/
|
|
209
|
+
fieldErrors?: FieldErrorMap;
|
|
210
|
+
/**
|
|
211
|
+
* Optional list of `x-secret` field keys whose value is already stored
|
|
212
|
+
* server-side (EDIT mode). A blank input on such a field means "keep the
|
|
213
|
+
* existing secret" and is therefore VALID, not missing-required - the
|
|
214
|
+
* redacted preview never returns the value, so the input is expected to be
|
|
215
|
+
* empty. CREATE mode omits this (or passes `[]`) so blank required secrets
|
|
216
|
+
* stay invalid. Drives both the validity boolean and the inline errors.
|
|
217
|
+
*/
|
|
218
|
+
keepExistingSecretFields?: string[];
|
|
161
219
|
}
|
|
162
220
|
|
|
163
221
|
/** Props for the FormField component */
|
|
@@ -178,7 +236,11 @@ export interface FormFieldProps {
|
|
|
178
236
|
secretNames?: string[];
|
|
179
237
|
acquireTypes?: AcquireTypes;
|
|
180
238
|
acquireResetKey?: string;
|
|
239
|
+
sdkTypes?: ReadonlyArray<AcquiredTypeFile>;
|
|
240
|
+
sdkTypesResetKey?: string;
|
|
181
241
|
importablePackages?: string[];
|
|
242
|
+
/** Sample context for previewing `x-templatable` fields. */
|
|
243
|
+
templatePreviewContext?: TemplatePreviewContext;
|
|
182
244
|
/**
|
|
183
245
|
* Current value of the sibling `x-secret-env` mapping field within the
|
|
184
246
|
* SAME config object as this field, located by annotation. Threaded down
|