@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.
- package/.storybook/main.ts +43 -0
- package/CHANGELOG.md +326 -0
- package/package.json +23 -18
- package/scripts/generate-stdlib-types.ts +23 -0
- package/src/components/Accordion.tsx +17 -9
- package/src/components/ActionCard.tsx +99 -11
- package/src/components/BrandIcon.tsx +57 -0
- package/src/components/CodeEditor/CodeEditor.tsx +159 -14
- package/src/components/CodeEditor/TypefoxEditor.tsx +537 -168
- package/src/components/CodeEditor/editorTheme.test.ts +41 -0
- package/src/components/CodeEditor/editorTheme.ts +26 -0
- 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/monacoGuard.ts +76 -0
- package/src/components/CodeEditor/monacoTsService.ts +185 -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 +15 -7
- package/src/components/CodeEditor/scriptContext.ts +12 -18
- package/src/components/CodeEditor/scriptDiagnostics.test.ts +135 -0
- package/src/components/CodeEditor/scriptDiagnostics.ts +172 -0
- package/src/components/CodeEditor/types.ts +79 -0
- package/src/components/CodeEditor/validateScripts.ts +172 -0
- package/src/components/CodeEditor/vscodeServicesSignal.ts +72 -0
- package/src/components/ConfirmationModal.tsx +7 -1
- package/src/components/Dialog.tsx +32 -11
- package/src/components/DurationInput.tsx +121 -0
- package/src/components/DynamicForm/DynamicForm.tsx +119 -47
- package/src/components/DynamicForm/DynamicOptionsField.tsx +19 -14
- package/src/components/DynamicForm/FormField.tsx +183 -15
- package/src/components/DynamicForm/MultiTypeEditorField.tsx +78 -2
- package/src/components/DynamicForm/SecretEnvEditor.tsx +315 -0
- package/src/components/DynamicForm/index.ts +20 -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 +134 -1
- package/src/components/DynamicForm/utils.test.ts +38 -0
- package/src/components/DynamicForm/utils.ts +54 -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/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/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/TimeOfDayInput.tsx +116 -0
- 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/comboboxInteraction.ts +39 -0
- package/src/components/iconRegistry.tsx +27 -0
- package/src/components/portalContainer.ts +24 -0
- package/src/index.ts +7 -0
- package/stories/ActionCard.stories.tsx +60 -0
- package/stories/CodeEditor.stories.tsx +47 -2
- package/stories/DurationInput.stories.tsx +59 -0
- package/stories/Introduction.mdx +1 -1
- package/stories/Markdown.stories.tsx +56 -0
- package/stories/ScriptTestPanel.stories.tsx +106 -0
- package/stories/SecretEnvEditor.stories.tsx +80 -0
- package/stories/Spinner.stories.tsx +90 -0
- package/stories/TimeOfDayInput.stories.tsx +34 -0
- 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 {
|
|
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
|
-
//
|
|
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,
|
|
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
|
-
<
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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 {
|
|
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>
|