@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
|
@@ -1,4 +1,10 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import type React from "react";
|
|
2
|
+
import type {
|
|
3
|
+
TemplateProperty,
|
|
4
|
+
ShellEnvVar,
|
|
5
|
+
AcquireTypes,
|
|
6
|
+
AcquiredTypeFile,
|
|
7
|
+
} from "../CodeEditor";
|
|
2
8
|
import type { TemplateCompletionProvider } from "../TemplateValueInput";
|
|
3
9
|
import type { EditorType } from "@checkstack/common";
|
|
4
10
|
|
|
@@ -11,6 +17,7 @@ import type {
|
|
|
11
17
|
JsonSchemaPropertyCore,
|
|
12
18
|
JsonSchemaBase,
|
|
13
19
|
} from "@checkstack/common";
|
|
20
|
+
import type { FieldErrorMap } from "./validation.logic";
|
|
14
21
|
|
|
15
22
|
/**
|
|
16
23
|
* JSON Schema property with DynamicForm-specific x-* extensions for config rendering.
|
|
@@ -26,8 +33,48 @@ export interface JsonSchemaProperty extends JsonSchemaPropertyCore<JsonSchemaPro
|
|
|
26
33
|
"x-searchable"?: boolean; // Shows search input for filtering dropdown options
|
|
27
34
|
"x-editor-types"?: EditorType[]; // Available editor types for multi-type input
|
|
28
35
|
"x-hidden-when"?: Record<string, string[]>; // Conditionally hide based on sibling field values
|
|
36
|
+
"x-duration"?: boolean; // Render a DurationInput (single-unit duration object)
|
|
37
|
+
"x-script-testable"?: boolean; // Field is an inline script that can be tested in-UI
|
|
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
|
|
29
40
|
}
|
|
30
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
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Renders the inline script-test UI beneath a testable script field. The
|
|
54
|
+
* owning feature page supplies this; it owns the RPC call + sample-context
|
|
55
|
+
* state and typically renders a `ScriptTestPanel`. The form only decides
|
|
56
|
+
* *where* it appears (below any `x-script-testable` field whose selected
|
|
57
|
+
* editor type is a code language). Mirrors the callback-prop pattern used
|
|
58
|
+
* by `optionsResolvers` / `templateCompletionProvider`.
|
|
59
|
+
*/
|
|
60
|
+
export type ScriptTestRenderer = (args: {
|
|
61
|
+
/** The field's id (form key). */
|
|
62
|
+
fieldId: string;
|
|
63
|
+
/** Editor language currently selected in the field. */
|
|
64
|
+
kind: "typescript" | "shell";
|
|
65
|
+
/** Current script source in the field. */
|
|
66
|
+
script: string;
|
|
67
|
+
/**
|
|
68
|
+
* The current value of the SIBLING secret→env mapping field (the field
|
|
69
|
+
* annotated `x-secret-env` within the same config object as the script),
|
|
70
|
+
* if any. DynamicForm locates it by the annotation — not by name — so the
|
|
71
|
+
* test panel can inject `__SECRET_<NAME>__` placeholders (or the operator's
|
|
72
|
+
* overrides) for the same secrets the real action declares. `undefined`
|
|
73
|
+
* when the config has no `x-secret-env` field or it's empty.
|
|
74
|
+
*/
|
|
75
|
+
secretEnv?: Record<string, string>;
|
|
76
|
+
}) => React.ReactNode;
|
|
77
|
+
|
|
31
78
|
/** Option returned by an options resolver */
|
|
32
79
|
export interface ResolverOption {
|
|
33
80
|
value: string;
|
|
@@ -98,6 +145,77 @@ export interface DynamicFormProps {
|
|
|
98
145
|
* fields with a working example. Keyed by `EditorType`.
|
|
99
146
|
*/
|
|
100
147
|
starterTemplates?: EditorStarterTemplates;
|
|
148
|
+
/**
|
|
149
|
+
* Optional renderer for the inline script-test panel. When supplied,
|
|
150
|
+
* fields flagged `x-script-testable` (whose selected editor type is a
|
|
151
|
+
* code language) render this beneath the editor so operators can run
|
|
152
|
+
* the script against a sample context. Omit it and no test UI appears.
|
|
153
|
+
*/
|
|
154
|
+
scriptTestRenderer?: ScriptTestRenderer;
|
|
155
|
+
/**
|
|
156
|
+
* Optional list of secret NAMES (never values) for `x-secret-env` record
|
|
157
|
+
* fields. The owning page fetches these from the secrets plugin's
|
|
158
|
+
* `listSecretNames` and passes them here so the secret-env editor offers
|
|
159
|
+
* name autocomplete. Omit it and the editor still works as free text.
|
|
160
|
+
*/
|
|
161
|
+
secretNames?: string[];
|
|
162
|
+
/**
|
|
163
|
+
* Optional lazy type-acquisition resolver forwarded to TS/JS editor-type
|
|
164
|
+
* fields. When supplied, the editor fetches + registers the `.d.ts` of any
|
|
165
|
+
* npm package the script imports, so `import { x } from "pkg"`
|
|
166
|
+
* autocompletes. Injected by the owning page (see
|
|
167
|
+
* `@checkstack/script-packages-frontend`).
|
|
168
|
+
*/
|
|
169
|
+
acquireTypes?: AcquireTypes;
|
|
170
|
+
/** Install identity (lockfile hash); resets acquired types on a new install. */
|
|
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;
|
|
180
|
+
/**
|
|
181
|
+
* Importable installed package names (already `@types/*`-free), forwarded to
|
|
182
|
+
* TS/JS editors so the import specifier itself autocompletes
|
|
183
|
+
* (`import {} from "lod"` -> `lodash`).
|
|
184
|
+
*/
|
|
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[];
|
|
101
219
|
}
|
|
102
220
|
|
|
103
221
|
/** Props for the FormField component */
|
|
@@ -114,6 +232,21 @@ export interface FormFieldProps {
|
|
|
114
232
|
typeDefinitions?: string;
|
|
115
233
|
shellEnvVars?: ShellEnvVar[];
|
|
116
234
|
starterTemplates?: EditorStarterTemplates;
|
|
235
|
+
scriptTestRenderer?: ScriptTestRenderer;
|
|
236
|
+
secretNames?: string[];
|
|
237
|
+
acquireTypes?: AcquireTypes;
|
|
238
|
+
acquireResetKey?: string;
|
|
239
|
+
sdkTypes?: ReadonlyArray<AcquiredTypeFile>;
|
|
240
|
+
sdkTypesResetKey?: string;
|
|
241
|
+
importablePackages?: string[];
|
|
242
|
+
/** Sample context for previewing `x-templatable` fields. */
|
|
243
|
+
templatePreviewContext?: TemplatePreviewContext;
|
|
244
|
+
/**
|
|
245
|
+
* Current value of the sibling `x-secret-env` mapping field within the
|
|
246
|
+
* SAME config object as this field, located by annotation. Threaded down
|
|
247
|
+
* so a testable script field can forward it to {@link ScriptTestRenderer}.
|
|
248
|
+
*/
|
|
249
|
+
siblingSecretEnv?: Record<string, string>;
|
|
117
250
|
/** Callback when value changes. Omit val to clear the field. */
|
|
118
251
|
onChange: (val?: unknown) => void;
|
|
119
252
|
}
|
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
extractDefaults,
|
|
6
6
|
getCleanDescription,
|
|
7
7
|
isValueEmpty,
|
|
8
|
+
nestedChildrenRequired,
|
|
8
9
|
isFieldHiddenByCondition,
|
|
9
10
|
NONE_SENTINEL,
|
|
10
11
|
parseSelectValue,
|
|
@@ -201,6 +202,43 @@ describe("isValueEmpty", () => {
|
|
|
201
202
|
});
|
|
202
203
|
});
|
|
203
204
|
|
|
205
|
+
describe("nestedChildrenRequired", () => {
|
|
206
|
+
it("marks children of a REQUIRED object regardless of value", () => {
|
|
207
|
+
expect(
|
|
208
|
+
nestedChildrenRequired({ objectRequired: true, objectValue: undefined }),
|
|
209
|
+
).toBe(true);
|
|
210
|
+
expect(
|
|
211
|
+
nestedChildrenRequired({ objectRequired: true, objectValue: {} }),
|
|
212
|
+
).toBe(true);
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
it("does NOT mark children of an OPTIONAL object that is empty/unset", () => {
|
|
216
|
+
// The spend-cap case: empty optional object -> no required `*`.
|
|
217
|
+
expect(
|
|
218
|
+
nestedChildrenRequired({ objectRequired: false, objectValue: undefined }),
|
|
219
|
+
).toBe(false);
|
|
220
|
+
expect(
|
|
221
|
+
nestedChildrenRequired({ objectRequired: false, objectValue: {} }),
|
|
222
|
+
).toBe(false);
|
|
223
|
+
expect(
|
|
224
|
+
nestedChildrenRequired({
|
|
225
|
+
objectRequired: false,
|
|
226
|
+
objectValue: { tokenBudget: "", windowMinutes: "" },
|
|
227
|
+
}),
|
|
228
|
+
).toBe(false);
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it("marks children of an OPTIONAL object once it is being provided", () => {
|
|
232
|
+
// Operator started filling the cap -> guide completion with `*`.
|
|
233
|
+
expect(
|
|
234
|
+
nestedChildrenRequired({
|
|
235
|
+
objectRequired: false,
|
|
236
|
+
objectValue: { tokenBudget: 1000, windowMinutes: "" },
|
|
237
|
+
}),
|
|
238
|
+
).toBe(true);
|
|
239
|
+
});
|
|
240
|
+
});
|
|
241
|
+
|
|
204
242
|
describe("NONE_SENTINEL", () => {
|
|
205
243
|
it("is a specific string constant", () => {
|
|
206
244
|
expect(NONE_SENTINEL).toBe("__none__");
|
|
@@ -69,6 +69,60 @@ export function isValueEmpty(
|
|
|
69
69
|
return false;
|
|
70
70
|
}
|
|
71
71
|
|
|
72
|
+
/**
|
|
73
|
+
* Whether a nested object's schema-required children should display the
|
|
74
|
+
* required `*` marker. A REQUIRED nested object always marks its required
|
|
75
|
+
* children. An OPTIONAL nested object (e.g. an opt-in spend cap) only marks
|
|
76
|
+
* them once the operator is actually providing the object (any child has a
|
|
77
|
+
* non-empty value) — while it is empty, supplying it is optional, so its
|
|
78
|
+
* children must not show `*` (the form is valid without any of them).
|
|
79
|
+
*/
|
|
80
|
+
export function nestedChildrenRequired({
|
|
81
|
+
objectRequired,
|
|
82
|
+
objectValue,
|
|
83
|
+
}: {
|
|
84
|
+
objectRequired: boolean;
|
|
85
|
+
objectValue: unknown;
|
|
86
|
+
}): boolean {
|
|
87
|
+
if (objectRequired) return true;
|
|
88
|
+
if (objectValue === null || typeof objectValue !== "object") return false;
|
|
89
|
+
return Object.values(objectValue as Record<string, unknown>).some(
|
|
90
|
+
(entry) => entry !== undefined && entry !== null && entry !== "",
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Locate the value of the secret→env mapping field within an object's
|
|
96
|
+
* properties by the `x-secret-env` annotation (NOT by a hard-coded field
|
|
97
|
+
* name), and return it. Used to feed the inline script-test panel the same
|
|
98
|
+
* `secretEnv` the sibling action declares, so a test injects placeholders /
|
|
99
|
+
* overrides for those secrets. Returns `undefined` when no `x-secret-env`
|
|
100
|
+
* field exists or its value isn't a record.
|
|
101
|
+
*/
|
|
102
|
+
export function findSecretEnvSibling({
|
|
103
|
+
properties,
|
|
104
|
+
values,
|
|
105
|
+
}: {
|
|
106
|
+
properties: Record<string, JsonSchemaProperty> | undefined;
|
|
107
|
+
values: Record<string, unknown> | undefined;
|
|
108
|
+
}): Record<string, string> | undefined {
|
|
109
|
+
if (!properties || !values) return undefined;
|
|
110
|
+
for (const [key, propSchema] of Object.entries(properties)) {
|
|
111
|
+
if (propSchema["x-secret-env"] === true) {
|
|
112
|
+
const value = values[key];
|
|
113
|
+
if (value && typeof value === "object" && !Array.isArray(value)) {
|
|
114
|
+
const record: Record<string, string> = {};
|
|
115
|
+
for (const [k, v] of Object.entries(value)) {
|
|
116
|
+
if (typeof v === "string") record[k] = v;
|
|
117
|
+
}
|
|
118
|
+
return record;
|
|
119
|
+
}
|
|
120
|
+
return undefined;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return undefined;
|
|
124
|
+
}
|
|
125
|
+
|
|
72
126
|
/** Sentinel value used to represent "None" selection in Select components */
|
|
73
127
|
export const NONE_SENTINEL = "__none__";
|
|
74
128
|
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
deriveClientFieldErrors,
|
|
5
|
+
deriveServerFieldErrors,
|
|
6
|
+
parseServerValidationData,
|
|
7
|
+
omitKeepExistingSecrets,
|
|
8
|
+
listSecretFieldKeys,
|
|
9
|
+
} from "./validation.logic";
|
|
10
|
+
import type { JsonSchema } from "./types";
|
|
11
|
+
|
|
12
|
+
const connectionSchema: JsonSchema = {
|
|
13
|
+
type: "object",
|
|
14
|
+
required: ["apiKey", "baseUrl"],
|
|
15
|
+
properties: {
|
|
16
|
+
apiKey: { type: "string", "x-secret": true },
|
|
17
|
+
baseUrl: { type: "string" },
|
|
18
|
+
organization: { type: "string" },
|
|
19
|
+
spendCap: {
|
|
20
|
+
type: "object",
|
|
21
|
+
required: ["tokenBudget"],
|
|
22
|
+
properties: {
|
|
23
|
+
tokenBudget: { type: "number" },
|
|
24
|
+
},
|
|
25
|
+
},
|
|
26
|
+
},
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
describe("deriveClientFieldErrors", () => {
|
|
30
|
+
it("flags empty required fields", () => {
|
|
31
|
+
const errors = deriveClientFieldErrors({
|
|
32
|
+
schema: connectionSchema,
|
|
33
|
+
value: { apiKey: "", baseUrl: undefined },
|
|
34
|
+
});
|
|
35
|
+
expect(errors.apiKey).toBeDefined();
|
|
36
|
+
expect(errors.baseUrl).toBeDefined();
|
|
37
|
+
expect(errors.apiKey).toContain("required");
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("returns no error for filled required fields", () => {
|
|
41
|
+
const errors = deriveClientFieldErrors({
|
|
42
|
+
schema: connectionSchema,
|
|
43
|
+
value: { apiKey: "sk-123", baseUrl: "https://api.example.com" },
|
|
44
|
+
});
|
|
45
|
+
expect(errors.apiKey).toBeUndefined();
|
|
46
|
+
expect(errors.baseUrl).toBeUndefined();
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("never flags optional empty fields", () => {
|
|
50
|
+
const errors = deriveClientFieldErrors({
|
|
51
|
+
schema: connectionSchema,
|
|
52
|
+
value: { apiKey: "sk-123", baseUrl: "https://x", organization: "" },
|
|
53
|
+
});
|
|
54
|
+
expect(errors.organization).toBeUndefined();
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("skips required fields hidden by x-hidden", () => {
|
|
58
|
+
const schema: JsonSchema = {
|
|
59
|
+
type: "object",
|
|
60
|
+
required: ["connectionId"],
|
|
61
|
+
properties: { connectionId: { type: "string", "x-hidden": true } },
|
|
62
|
+
};
|
|
63
|
+
const errors = deriveClientFieldErrors({ schema, value: {} });
|
|
64
|
+
expect(errors.connectionId).toBeUndefined();
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("skips required fields hidden by x-hidden-when", () => {
|
|
68
|
+
const schema: JsonSchema = {
|
|
69
|
+
type: "object",
|
|
70
|
+
required: ["token"],
|
|
71
|
+
properties: {
|
|
72
|
+
mode: { type: "string" },
|
|
73
|
+
token: { type: "string", "x-hidden-when": { mode: ["anonymous"] } },
|
|
74
|
+
},
|
|
75
|
+
};
|
|
76
|
+
const errors = deriveClientFieldErrors({
|
|
77
|
+
schema,
|
|
78
|
+
value: { mode: "anonymous" },
|
|
79
|
+
});
|
|
80
|
+
expect(errors.token).toBeUndefined();
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("flags a required nested object whose required child is empty", () => {
|
|
84
|
+
const errors = deriveClientFieldErrors({
|
|
85
|
+
schema: { ...connectionSchema, required: ["spendCap"] },
|
|
86
|
+
value: { spendCap: {} },
|
|
87
|
+
});
|
|
88
|
+
expect(errors.spendCap).toBeDefined();
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("returns an empty map for a schema with no properties", () => {
|
|
92
|
+
const errors = deriveClientFieldErrors({
|
|
93
|
+
schema: { type: "object" },
|
|
94
|
+
value: {},
|
|
95
|
+
});
|
|
96
|
+
expect(errors).toEqual({});
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("edit + blank x-secret + existing secret => valid (no required error)", () => {
|
|
100
|
+
const errors = deriveClientFieldErrors({
|
|
101
|
+
schema: connectionSchema,
|
|
102
|
+
value: { apiKey: "", baseUrl: "https://api.example.com" },
|
|
103
|
+
keepExistingSecretFields: ["apiKey"],
|
|
104
|
+
});
|
|
105
|
+
expect(errors.apiKey).toBeUndefined();
|
|
106
|
+
expect(errors.baseUrl).toBeUndefined();
|
|
107
|
+
expect(Object.keys(errors)).toHaveLength(0);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("create + blank x-secret => required error", () => {
|
|
111
|
+
const errors = deriveClientFieldErrors({
|
|
112
|
+
schema: connectionSchema,
|
|
113
|
+
value: { apiKey: "", baseUrl: "https://api.example.com" },
|
|
114
|
+
keepExistingSecretFields: [],
|
|
115
|
+
});
|
|
116
|
+
expect(errors.apiKey).toBeDefined();
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("edit + blank x-secret + no existing secret for that field => required error", () => {
|
|
120
|
+
// apiKey was never set, so it is NOT in keepExistingSecretFields.
|
|
121
|
+
const errors = deriveClientFieldErrors({
|
|
122
|
+
schema: connectionSchema,
|
|
123
|
+
value: { apiKey: "", baseUrl: "https://api.example.com" },
|
|
124
|
+
keepExistingSecretFields: ["someOtherSecret"],
|
|
125
|
+
});
|
|
126
|
+
expect(errors.apiKey).toBeDefined();
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it("does not exempt a blank NON-secret required field via keepExisting", () => {
|
|
130
|
+
const errors = deriveClientFieldErrors({
|
|
131
|
+
schema: connectionSchema,
|
|
132
|
+
value: { apiKey: "sk-1", baseUrl: "" },
|
|
133
|
+
keepExistingSecretFields: ["baseUrl"],
|
|
134
|
+
});
|
|
135
|
+
// baseUrl is not x-secret, so the keep-existing exemption must not apply.
|
|
136
|
+
expect(errors.baseUrl).toBeDefined();
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
describe("listSecretFieldKeys", () => {
|
|
141
|
+
it("lists only x-secret field keys", () => {
|
|
142
|
+
expect(listSecretFieldKeys(connectionSchema)).toEqual(["apiKey"]);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it("returns an empty list when there are no secret fields", () => {
|
|
146
|
+
expect(listSecretFieldKeys({ type: "object", properties: {} })).toEqual([]);
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
describe("omitKeepExistingSecrets", () => {
|
|
151
|
+
it("strips a blank keep-existing secret so it is not sent on submit", () => {
|
|
152
|
+
const result = omitKeepExistingSecrets({
|
|
153
|
+
schema: connectionSchema,
|
|
154
|
+
value: { apiKey: "", baseUrl: "https://x", organization: "acme" },
|
|
155
|
+
keepExistingSecretFields: ["apiKey"],
|
|
156
|
+
});
|
|
157
|
+
expect("apiKey" in result).toBe(false);
|
|
158
|
+
expect(result.baseUrl).toBe("https://x");
|
|
159
|
+
expect(result.organization).toBe("acme");
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it("keeps a freshly-typed secret value", () => {
|
|
163
|
+
const result = omitKeepExistingSecrets({
|
|
164
|
+
schema: connectionSchema,
|
|
165
|
+
value: { apiKey: "sk-new", baseUrl: "https://x" },
|
|
166
|
+
keepExistingSecretFields: ["apiKey"],
|
|
167
|
+
});
|
|
168
|
+
expect(result.apiKey).toBe("sk-new");
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it("strips nothing on create (empty keep-existing list)", () => {
|
|
172
|
+
const value = { apiKey: "", baseUrl: "https://x" };
|
|
173
|
+
const result = omitKeepExistingSecrets({
|
|
174
|
+
schema: connectionSchema,
|
|
175
|
+
value,
|
|
176
|
+
keepExistingSecretFields: [],
|
|
177
|
+
});
|
|
178
|
+
expect(result).toEqual(value);
|
|
179
|
+
});
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
describe("parseServerValidationData", () => {
|
|
183
|
+
it("parses a well-formed config-validation payload", () => {
|
|
184
|
+
const parsed = parseServerValidationData({
|
|
185
|
+
code: "CONFIG_VALIDATION",
|
|
186
|
+
issues: [{ path: ["apiKey"], message: "Required" }],
|
|
187
|
+
});
|
|
188
|
+
expect(parsed?.issues).toHaveLength(1);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it("returns undefined for an unrelated payload", () => {
|
|
192
|
+
expect(parseServerValidationData(undefined)).toBeUndefined();
|
|
193
|
+
expect(parseServerValidationData({ code: "OTHER" })).toBeUndefined();
|
|
194
|
+
expect(parseServerValidationData("boom")).toBeUndefined();
|
|
195
|
+
});
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
describe("deriveServerFieldErrors", () => {
|
|
199
|
+
it("maps issues with a known top-level path", () => {
|
|
200
|
+
const { mapped, unmapped } = deriveServerFieldErrors({
|
|
201
|
+
schema: connectionSchema,
|
|
202
|
+
issues: [{ path: ["apiKey"], message: "API key is invalid" }],
|
|
203
|
+
});
|
|
204
|
+
expect(mapped.apiKey).toBe("API key is invalid");
|
|
205
|
+
expect(unmapped).toHaveLength(0);
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it("maps nested paths with dot notation", () => {
|
|
209
|
+
const { mapped } = deriveServerFieldErrors({
|
|
210
|
+
schema: connectionSchema,
|
|
211
|
+
issues: [
|
|
212
|
+
{ path: ["spendCap", "tokenBudget"], message: "Must be positive" },
|
|
213
|
+
],
|
|
214
|
+
});
|
|
215
|
+
expect(mapped["spendCap.tokenBudget"]).toBe("Must be positive");
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it("falls back unmapped issues whose root is unknown", () => {
|
|
219
|
+
const { mapped, unmapped } = deriveServerFieldErrors({
|
|
220
|
+
schema: connectionSchema,
|
|
221
|
+
issues: [{ path: ["nonexistent"], message: "Surprise" }],
|
|
222
|
+
});
|
|
223
|
+
expect(Object.keys(mapped)).toHaveLength(0);
|
|
224
|
+
expect(unmapped).toEqual(["Surprise"]);
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it("treats an empty path as unmappable", () => {
|
|
228
|
+
const { mapped, unmapped } = deriveServerFieldErrors({
|
|
229
|
+
schema: connectionSchema,
|
|
230
|
+
issues: [{ path: [], message: "Whole object is wrong" }],
|
|
231
|
+
});
|
|
232
|
+
expect(Object.keys(mapped)).toHaveLength(0);
|
|
233
|
+
expect(unmapped).toEqual(["Whole object is wrong"]);
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
it("keeps the first message when a path repeats", () => {
|
|
237
|
+
const { mapped } = deriveServerFieldErrors({
|
|
238
|
+
schema: connectionSchema,
|
|
239
|
+
issues: [
|
|
240
|
+
{ path: ["apiKey"], message: "First" },
|
|
241
|
+
{ path: ["apiKey"], message: "Second" },
|
|
242
|
+
],
|
|
243
|
+
});
|
|
244
|
+
expect(mapped.apiKey).toBe("First");
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
it("returns empty maps for no issues", () => {
|
|
248
|
+
const { mapped, unmapped } = deriveServerFieldErrors({
|
|
249
|
+
schema: connectionSchema,
|
|
250
|
+
issues: [],
|
|
251
|
+
});
|
|
252
|
+
expect(mapped).toEqual({});
|
|
253
|
+
expect(unmapped).toEqual([]);
|
|
254
|
+
});
|
|
255
|
+
});
|