@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.
Files changed (43) hide show
  1. package/CHANGELOG.md +145 -0
  2. package/package.json +20 -15
  3. package/src/components/Accordion.tsx +17 -9
  4. package/src/components/ActionCard.tsx +4 -4
  5. package/src/components/BrandIcon.tsx +57 -0
  6. package/src/components/CodeEditor/CodeEditor.tsx +71 -7
  7. package/src/components/CodeEditor/TypefoxEditor.tsx +266 -53
  8. package/src/components/CodeEditor/editorTheme.test.ts +41 -0
  9. package/src/components/CodeEditor/editorTheme.ts +26 -0
  10. package/src/components/CodeEditor/index.ts +3 -1
  11. package/src/components/CodeEditor/monacoGuard.ts +76 -0
  12. package/src/components/CodeEditor/monacoTsService.ts +5 -37
  13. package/src/components/CodeEditor/scriptContext.test.ts +15 -7
  14. package/src/components/CodeEditor/scriptContext.ts +12 -18
  15. package/src/components/CodeEditor/types.ts +20 -0
  16. package/src/components/CodeEditor/validateScripts.ts +53 -13
  17. package/src/components/CodeEditor/vscodeServicesSignal.ts +72 -0
  18. package/src/components/ConfirmationModal.tsx +7 -1
  19. package/src/components/DynamicForm/DynamicForm.tsx +101 -53
  20. package/src/components/DynamicForm/DynamicOptionsField.tsx +19 -14
  21. package/src/components/DynamicForm/FormField.tsx +84 -24
  22. package/src/components/DynamicForm/MultiTypeEditorField.tsx +11 -0
  23. package/src/components/DynamicForm/index.ts +14 -0
  24. package/src/components/DynamicForm/types.ts +63 -1
  25. package/src/components/DynamicForm/utils.test.ts +38 -0
  26. package/src/components/DynamicForm/utils.ts +22 -0
  27. package/src/components/DynamicForm/validation.logic.test.ts +255 -0
  28. package/src/components/DynamicForm/validation.logic.ts +210 -0
  29. package/src/components/DynamicIcon.tsx +39 -17
  30. package/src/components/Markdown.tsx +68 -2
  31. package/src/components/Spinner.tsx +56 -0
  32. package/src/components/StatusBadge.tsx +78 -0
  33. package/src/components/StrategyConfigCard.tsx +3 -3
  34. package/src/components/Tabs.tsx +7 -1
  35. package/src/components/UserMenu.logic.test.ts +37 -0
  36. package/src/components/UserMenu.logic.ts +30 -0
  37. package/src/components/UserMenu.tsx +40 -12
  38. package/src/components/iconRegistry.tsx +27 -0
  39. package/src/index.ts +3 -0
  40. package/stories/Introduction.mdx +1 -1
  41. package/stories/Markdown.stories.tsx +56 -0
  42. package/stories/Spinner.stories.tsx +90 -0
  43. 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
- // Compute validity and report changes
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, value, onValidChange]);
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
- <FormField
127
- key={key}
128
- // Prefix with 'field-' to prevent DOM clobbering when field names
129
- // match native DOM properties (e.g., nodeName, tagName, innerHTML)
130
- id={`field-${key}`}
131
- label={label}
132
- propSchema={propSchema}
133
- value={value[key]}
134
- isRequired={isRequired}
135
- formValues={value}
136
- optionsResolvers={optionsResolvers}
137
- templateProperties={templateProperties}
138
- templateCompletionProvider={templateCompletionProvider}
139
- typeDefinitions={typeDefinitions}
140
- shellEnvVars={shellEnvVars}
141
- starterTemplates={starterTemplates}
142
- scriptTestRenderer={scriptTestRenderer}
143
- secretNames={secretNames}
144
- acquireTypes={acquireTypes}
145
- acquireResetKey={acquireResetKey}
146
- importablePackages={importablePackages}
147
- siblingSecretEnv={rootSecretEnv}
148
- onChange={(val) => onChange({ ...value, [key]: val })}
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 { Loader2, ChevronDown } from "lucide-react";
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
- usePerformance,
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 = optionsResolvers[resolverName];
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 ref to get current form values without adding to dependencies
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 changes or explicit dependencies change
87
- }, [resolverName, optionsResolvers, dependencyValues]);
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
- <Loader2
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
- <MultiTypeEditorField
171
- id={id}
172
- label={label}
173
- description={cleanDesc}
174
- value={value as string | undefined}
175
- isRequired={isRequired}
176
- editorTypes={editorTypes}
177
- templateProperties={templateProperties}
178
- typeDefinitions={typeDefinitions}
179
- shellEnvVars={shellEnvVars}
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}
191
- onChange={onChange as (val: string | undefined) => void}
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 { TemplateProperty, ShellEnvVar, AcquireTypes } from "../CodeEditor";
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