@checkstack/ui 1.11.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 (71) hide show
  1. package/.storybook/main.ts +43 -0
  2. package/CHANGELOG.md +326 -0
  3. package/package.json +23 -18
  4. package/scripts/generate-stdlib-types.ts +23 -0
  5. package/src/components/Accordion.tsx +17 -9
  6. package/src/components/ActionCard.tsx +99 -11
  7. package/src/components/BrandIcon.tsx +57 -0
  8. package/src/components/CodeEditor/CodeEditor.tsx +159 -14
  9. package/src/components/CodeEditor/TypefoxEditor.tsx +537 -168
  10. package/src/components/CodeEditor/editorTheme.test.ts +41 -0
  11. package/src/components/CodeEditor/editorTheme.ts +26 -0
  12. package/src/components/CodeEditor/generated/builtin-modules.json +1 -0
  13. package/src/components/CodeEditor/importSpecifiers.test.ts +286 -0
  14. package/src/components/CodeEditor/importSpecifiers.ts +267 -0
  15. package/src/components/CodeEditor/index.ts +26 -0
  16. package/src/components/CodeEditor/monacoGuard.ts +76 -0
  17. package/src/components/CodeEditor/monacoTsService.ts +185 -0
  18. package/src/components/CodeEditor/popoutTitle.test.ts +37 -0
  19. package/src/components/CodeEditor/popoutTitle.ts +31 -0
  20. package/src/components/CodeEditor/scriptContext.test.ts +15 -7
  21. package/src/components/CodeEditor/scriptContext.ts +12 -18
  22. package/src/components/CodeEditor/scriptDiagnostics.test.ts +135 -0
  23. package/src/components/CodeEditor/scriptDiagnostics.ts +172 -0
  24. package/src/components/CodeEditor/types.ts +79 -0
  25. package/src/components/CodeEditor/validateScripts.ts +172 -0
  26. package/src/components/CodeEditor/vscodeServicesSignal.ts +72 -0
  27. package/src/components/ConfirmationModal.tsx +7 -1
  28. package/src/components/Dialog.tsx +32 -11
  29. package/src/components/DurationInput.tsx +121 -0
  30. package/src/components/DynamicForm/DynamicForm.tsx +119 -47
  31. package/src/components/DynamicForm/DynamicOptionsField.tsx +19 -14
  32. package/src/components/DynamicForm/FormField.tsx +183 -15
  33. package/src/components/DynamicForm/MultiTypeEditorField.tsx +78 -2
  34. package/src/components/DynamicForm/SecretEnvEditor.tsx +315 -0
  35. package/src/components/DynamicForm/index.ts +20 -0
  36. package/src/components/DynamicForm/secretEnv.logic.test.ts +126 -0
  37. package/src/components/DynamicForm/secretEnv.logic.ts +87 -0
  38. package/src/components/DynamicForm/types.ts +134 -1
  39. package/src/components/DynamicForm/utils.test.ts +38 -0
  40. package/src/components/DynamicForm/utils.ts +54 -0
  41. package/src/components/DynamicForm/validation.logic.test.ts +255 -0
  42. package/src/components/DynamicForm/validation.logic.ts +210 -0
  43. package/src/components/DynamicIcon.tsx +39 -17
  44. package/src/components/Markdown.tsx +68 -2
  45. package/src/components/Popover.tsx +6 -1
  46. package/src/components/ScriptTestPanel.logic.test.ts +139 -0
  47. package/src/components/ScriptTestPanel.logic.ts +137 -0
  48. package/src/components/ScriptTestPanel.tsx +394 -0
  49. package/src/components/Sheet.tsx +21 -6
  50. package/src/components/Spinner.tsx +56 -0
  51. package/src/components/StatusBadge.tsx +78 -0
  52. package/src/components/StrategyConfigCard.tsx +3 -3
  53. package/src/components/Tabs.tsx +7 -1
  54. package/src/components/TimeOfDayInput.tsx +116 -0
  55. package/src/components/UserMenu.logic.test.ts +37 -0
  56. package/src/components/UserMenu.logic.ts +30 -0
  57. package/src/components/UserMenu.tsx +40 -12
  58. package/src/components/comboboxInteraction.ts +39 -0
  59. package/src/components/iconRegistry.tsx +27 -0
  60. package/src/components/portalContainer.ts +24 -0
  61. package/src/index.ts +7 -0
  62. package/stories/ActionCard.stories.tsx +60 -0
  63. package/stories/CodeEditor.stories.tsx +47 -2
  64. package/stories/DurationInput.stories.tsx +59 -0
  65. package/stories/Introduction.mdx +1 -1
  66. package/stories/Markdown.stories.tsx +56 -0
  67. package/stories/ScriptTestPanel.stories.tsx +106 -0
  68. package/stories/SecretEnvEditor.stories.tsx +80 -0
  69. package/stories/Spinner.stories.tsx +90 -0
  70. package/stories/TimeOfDayInput.stories.tsx +34 -0
  71. package/tsconfig.json +4 -0
@@ -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 { extractDefaults, isValueEmpty, isFieldHiddenByCondition } from "./utils";
6
+ import {
7
+ extractDefaults,
8
+ isFieldHiddenByCondition,
9
+ findSecretEnvSibling,
10
+ } from "./utils";
11
+ import { deriveClientFieldErrors } from "./validation.logic";
7
12
  import { FormField } from "./FormField";
8
13
 
9
14
  /**
@@ -22,10 +27,26 @@ export const DynamicForm: React.FC<DynamicFormProps> = ({
22
27
  typeDefinitions,
23
28
  shellEnvVars,
24
29
  starterTemplates,
30
+ scriptTestRenderer,
31
+ secretNames,
32
+ acquireTypes,
33
+ acquireResetKey,
34
+ sdkTypes,
35
+ sdkTypesResetKey,
36
+ importablePackages,
37
+ templatePreviewContext,
38
+ showInlineErrors = false,
39
+ fieldErrors,
40
+ keepExistingSecretFields,
25
41
  }) => {
26
42
  // Track previous validity to avoid redundant callbacks
27
43
  const prevValidRef = React.useRef<boolean | undefined>(undefined);
28
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
+
29
50
  // Initialize form with default values from schema
30
51
  React.useEffect(() => {
31
52
  if (!schema || !schema.properties) return;
@@ -40,40 +61,24 @@ export const DynamicForm: React.FC<DynamicFormProps> = ({
40
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)
41
62
  }, [schema]);
42
63
 
43
- // 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.
44
74
  React.useEffect(() => {
45
75
  if (!onValidChange || !schema || !schema.properties) return;
46
-
47
- // Check all required fields (including hidden ones like connectionId)
48
- const requiredKeys = schema.required ?? [];
49
- let isValid = true;
50
-
51
- for (const key of requiredKeys) {
52
- const propSchema = schema.properties[key];
53
- if (!propSchema) continue;
54
-
55
- // Skip hidden fields - they are auto-populated
56
- if (propSchema["x-hidden"]) continue;
57
-
58
- // Skip conditionally hidden fields - they are not visible
59
- if (
60
- propSchema["x-hidden-when"] &&
61
- isFieldHiddenByCondition(propSchema["x-hidden-when"], value)
62
- )
63
- continue;
64
-
65
- if (isValueEmpty(value[key], propSchema)) {
66
- isValid = false;
67
- break;
68
- }
69
- }
70
-
71
- // Only call onValidChange if validity actually changed
76
+ const isValid = Object.keys(clientErrors).length === 0;
72
77
  if (prevValidRef.current !== isValid) {
73
78
  prevValidRef.current = isValid;
74
79
  onValidChange(isValid);
75
80
  }
76
- }, [schema, value, onValidChange]);
81
+ }, [schema, clientErrors, onValidChange]);
77
82
 
78
83
  if (
79
84
  !schema ||
@@ -88,6 +93,14 @@ export const DynamicForm: React.FC<DynamicFormProps> = ({
88
93
  );
89
94
  }
90
95
 
96
+ // The script field and its `x-secret-env` field are top-level siblings in
97
+ // an action config, so resolve the mapping once at the root and forward it
98
+ // to every field; a testable script field passes it to the test panel.
99
+ const rootSecretEnv = findSecretEnvSibling({
100
+ properties: schema.properties,
101
+ values: value,
102
+ });
103
+
91
104
  return (
92
105
  <div className="space-y-6">
93
106
  {Object.entries(schema.properties)
@@ -104,27 +117,86 @@ export const DynamicForm: React.FC<DynamicFormProps> = ({
104
117
  const isRequired = schema.required?.includes(key);
105
118
  const label = key.charAt(0).toUpperCase() + key.slice(1);
106
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
+
107
134
  return (
108
- <FormField
109
- key={key}
110
- // Prefix with 'field-' to prevent DOM clobbering when field names
111
- // match native DOM properties (e.g., nodeName, tagName, innerHTML)
112
- id={`field-${key}`}
113
- label={label}
114
- propSchema={propSchema}
115
- value={value[key]}
116
- isRequired={isRequired}
117
- formValues={value}
118
- optionsResolvers={optionsResolvers}
119
- templateProperties={templateProperties}
120
- templateCompletionProvider={templateCompletionProvider}
121
- typeDefinitions={typeDefinitions}
122
- shellEnvVars={shellEnvVars}
123
- starterTemplates={starterTemplates}
124
- onChange={(val) => onChange({ ...value, [key]: val })}
125
- />
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>
126
173
  );
127
174
  })}
128
175
  </div>
129
176
  );
130
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>