@checkstack/ui 1.12.0 → 1.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +145 -0
- package/package.json +20 -15
- package/src/components/Accordion.tsx +17 -9
- package/src/components/ActionCard.tsx +4 -4
- package/src/components/BrandIcon.tsx +57 -0
- package/src/components/CodeEditor/CodeEditor.tsx +71 -7
- package/src/components/CodeEditor/TypefoxEditor.tsx +266 -53
- package/src/components/CodeEditor/editorTheme.test.ts +41 -0
- package/src/components/CodeEditor/editorTheme.ts +26 -0
- package/src/components/CodeEditor/index.ts +3 -1
- package/src/components/CodeEditor/monacoGuard.ts +76 -0
- package/src/components/CodeEditor/monacoTsService.ts +5 -37
- package/src/components/CodeEditor/scriptContext.test.ts +15 -7
- package/src/components/CodeEditor/scriptContext.ts +12 -18
- package/src/components/CodeEditor/types.ts +20 -0
- package/src/components/CodeEditor/validateScripts.ts +53 -13
- package/src/components/CodeEditor/vscodeServicesSignal.ts +72 -0
- package/src/components/ConfirmationModal.tsx +7 -1
- package/src/components/DynamicForm/DynamicForm.tsx +101 -53
- package/src/components/DynamicForm/DynamicOptionsField.tsx +19 -14
- package/src/components/DynamicForm/FormField.tsx +84 -24
- package/src/components/DynamicForm/MultiTypeEditorField.tsx +11 -0
- package/src/components/DynamicForm/index.ts +14 -0
- package/src/components/DynamicForm/types.ts +63 -1
- package/src/components/DynamicForm/utils.test.ts +38 -0
- package/src/components/DynamicForm/utils.ts +22 -0
- package/src/components/DynamicForm/validation.logic.test.ts +255 -0
- package/src/components/DynamicForm/validation.logic.ts +210 -0
- package/src/components/DynamicIcon.tsx +39 -17
- package/src/components/Markdown.tsx +68 -2
- package/src/components/Spinner.tsx +56 -0
- package/src/components/StatusBadge.tsx +78 -0
- package/src/components/StrategyConfigCard.tsx +3 -3
- package/src/components/Tabs.tsx +7 -1
- package/src/components/UserMenu.logic.test.ts +37 -0
- package/src/components/UserMenu.logic.ts +30 -0
- package/src/components/UserMenu.tsx +40 -12
- package/src/components/iconRegistry.tsx +27 -0
- package/src/index.ts +3 -0
- package/stories/Introduction.mdx +1 -1
- package/stories/Markdown.stories.tsx +56 -0
- package/stories/Spinner.stories.tsx +90 -0
- package/tsconfig.json +3 -0
|
@@ -5,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,28 @@ 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
|
+
|
|
72
94
|
/**
|
|
73
95
|
* Locate the value of the secret→env mapping field within an object's
|
|
74
96
|
* properties by the `x-secret-env` annotation (NOT by a hard-coded field
|
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
|
|
3
|
+
import type { JsonSchema, JsonSchemaProperty } from "./types";
|
|
4
|
+
import { isValueEmpty, isFieldHiddenByCondition } from "./utils";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* A map from a field path to a human-readable validation message. The path
|
|
8
|
+
* is dot-joined (e.g. `spendCap.tokenBudget`) so it lines up with the keys
|
|
9
|
+
* DynamicForm renders for nested object fields. The top-level segment is
|
|
10
|
+
* always the form field DynamicForm shows; nested segments identify the
|
|
11
|
+
* offending sub-field for the message text.
|
|
12
|
+
*/
|
|
13
|
+
export type FieldErrorMap = Record<string, string>;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Zod schema for a single zod issue as it crosses the oRPC boundary. The
|
|
17
|
+
* backend attaches the original `ZodError.issues` array to the
|
|
18
|
+
* `ORPCError.data` payload; oRPC serializes it to JSON and the client
|
|
19
|
+
* deserializes it, so we re-parse it here rather than trusting its shape.
|
|
20
|
+
* Only the fields we actually consume are required.
|
|
21
|
+
*/
|
|
22
|
+
const serverZodIssueSchema = z.object({
|
|
23
|
+
path: z.array(z.union([z.string(), z.number()])),
|
|
24
|
+
message: z.string(),
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Zod schema for the structured validation payload the backend places on
|
|
29
|
+
* `ORPCError.data` for connection-config validation failures. `code`
|
|
30
|
+
* discriminates this payload from any other structured error data so we
|
|
31
|
+
* never mis-map an unrelated error onto form fields.
|
|
32
|
+
*/
|
|
33
|
+
export const serverValidationDataSchema = z.object({
|
|
34
|
+
code: z.literal("CONFIG_VALIDATION"),
|
|
35
|
+
issues: z.array(serverZodIssueSchema),
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
export type ServerValidationData = z.infer<typeof serverValidationDataSchema>;
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Parse an unknown error payload (typically `error.data` from an oRPC
|
|
42
|
+
* `ORPCError`) into the structured connection-config validation shape.
|
|
43
|
+
* Returns `undefined` when the payload is not structured config-validation
|
|
44
|
+
* data, signalling the caller to fall back to a toast/banner.
|
|
45
|
+
*/
|
|
46
|
+
export function parseServerValidationData(
|
|
47
|
+
data: unknown,
|
|
48
|
+
): ServerValidationData | undefined {
|
|
49
|
+
const result = serverValidationDataSchema.safeParse(data);
|
|
50
|
+
return result.success ? result.data : undefined;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Map a structured server validation payload onto DynamicForm field paths.
|
|
55
|
+
*
|
|
56
|
+
* Each zod issue's `path` is joined with dots to form a field path. Only
|
|
57
|
+
* issues whose first path segment names a property that actually exists in
|
|
58
|
+
* the rendered schema are considered "mappable"; everything else (empty
|
|
59
|
+
* paths, unknown roots) is reported back as `unmapped` so the caller can
|
|
60
|
+
* surface it via the existing toast/banner instead of silently dropping it.
|
|
61
|
+
*
|
|
62
|
+
* When multiple issues target the same path, the first one wins (zod emits
|
|
63
|
+
* the most specific issue first for a given location).
|
|
64
|
+
*/
|
|
65
|
+
export function deriveServerFieldErrors({
|
|
66
|
+
issues,
|
|
67
|
+
schema,
|
|
68
|
+
}: {
|
|
69
|
+
issues: ServerValidationData["issues"];
|
|
70
|
+
schema: JsonSchema;
|
|
71
|
+
}): { mapped: FieldErrorMap; unmapped: string[] } {
|
|
72
|
+
const mapped: FieldErrorMap = {};
|
|
73
|
+
const unmapped: string[] = [];
|
|
74
|
+
const properties = schema.properties ?? {};
|
|
75
|
+
|
|
76
|
+
for (const issue of issues) {
|
|
77
|
+
const root = issue.path[0];
|
|
78
|
+
const rootKey = typeof root === "string" ? root : undefined;
|
|
79
|
+
|
|
80
|
+
if (rootKey === undefined || !(rootKey in properties)) {
|
|
81
|
+
unmapped.push(issue.message);
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const fieldPath = issue.path.map(String).join(".");
|
|
86
|
+
|
|
87
|
+
if (mapped[fieldPath] === undefined) {
|
|
88
|
+
mapped[fieldPath] = issue.message;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return { mapped, unmapped };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Compute client-side validation problems from the JSON schema and the
|
|
97
|
+
* current form values. Mirrors the validity computation DynamicForm already
|
|
98
|
+
* does for `onValidChange`, but returns a per-field message map instead of a
|
|
99
|
+
* single boolean so the offending fields can be flagged inline. The form is
|
|
100
|
+
* considered valid exactly when this map is empty, so the same call drives
|
|
101
|
+
* both the inline errors and the `onValidChange` boolean.
|
|
102
|
+
*
|
|
103
|
+
* At minimum this flags empty REQUIRED fields (reusing {@link isValueEmpty}
|
|
104
|
+
* so the notion of "empty" stays in lock-step). Hidden and
|
|
105
|
+
* conditionally-hidden required fields are skipped because the user cannot
|
|
106
|
+
* fill them. Optional empty fields never produce an error.
|
|
107
|
+
*
|
|
108
|
+
* `keepExistingSecretFields` lists `x-secret` field keys whose value is
|
|
109
|
+
* already stored server-side (edit mode). A blank input on such a field
|
|
110
|
+
* means "keep the existing secret" - the redacted preview never returns the
|
|
111
|
+
* value, so an empty input is expected and MUST count as valid rather than a
|
|
112
|
+
* missing-required error. A blank `x-secret` field NOT in this set (create
|
|
113
|
+
* mode, or a secret that was never set) is still treated as missing-required.
|
|
114
|
+
*/
|
|
115
|
+
export function deriveClientFieldErrors({
|
|
116
|
+
schema,
|
|
117
|
+
value,
|
|
118
|
+
keepExistingSecretFields = [],
|
|
119
|
+
}: {
|
|
120
|
+
schema: JsonSchema;
|
|
121
|
+
value: Record<string, unknown>;
|
|
122
|
+
keepExistingSecretFields?: string[];
|
|
123
|
+
}): FieldErrorMap {
|
|
124
|
+
const errors: FieldErrorMap = {};
|
|
125
|
+
const properties = schema.properties;
|
|
126
|
+
if (!properties) return errors;
|
|
127
|
+
|
|
128
|
+
const keepExisting = new Set(keepExistingSecretFields);
|
|
129
|
+
const requiredKeys = schema.required ?? [];
|
|
130
|
+
|
|
131
|
+
for (const key of requiredKeys) {
|
|
132
|
+
const propSchema: JsonSchemaProperty | undefined = properties[key];
|
|
133
|
+
if (!propSchema) continue;
|
|
134
|
+
|
|
135
|
+
// Hidden fields are auto-populated; the user cannot act on them.
|
|
136
|
+
if (propSchema["x-hidden"]) continue;
|
|
137
|
+
|
|
138
|
+
const hiddenWhen = propSchema["x-hidden-when"];
|
|
139
|
+
if (hiddenWhen && isFieldHiddenByCondition(hiddenWhen, value)) continue;
|
|
140
|
+
|
|
141
|
+
if (!isValueEmpty(value[key], propSchema)) continue;
|
|
142
|
+
|
|
143
|
+
// A blank secret that already has a stored value is "keep existing",
|
|
144
|
+
// not missing-required.
|
|
145
|
+
if (propSchema["x-secret"] === true && keepExisting.has(key)) continue;
|
|
146
|
+
|
|
147
|
+
errors[key] = `${labelForKey(key)} is required.`;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
return errors;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Strip blank `x-secret` fields that are flagged keep-existing from a config
|
|
155
|
+
* object before submit, so an empty input does not overwrite (clear) the
|
|
156
|
+
* stored secret. The update path treats an absent secret key as "leave the
|
|
157
|
+
* stored value untouched"; sending `""` would clear it. CREATE mode passes an
|
|
158
|
+
* empty `keepExistingSecretFields`, so nothing is stripped there.
|
|
159
|
+
*/
|
|
160
|
+
export function omitKeepExistingSecrets({
|
|
161
|
+
schema,
|
|
162
|
+
value,
|
|
163
|
+
keepExistingSecretFields,
|
|
164
|
+
}: {
|
|
165
|
+
schema: JsonSchema;
|
|
166
|
+
value: Record<string, unknown>;
|
|
167
|
+
keepExistingSecretFields: string[];
|
|
168
|
+
}): Record<string, unknown> {
|
|
169
|
+
const properties = schema.properties;
|
|
170
|
+
if (!properties || keepExistingSecretFields.length === 0) return value;
|
|
171
|
+
|
|
172
|
+
const keepExisting = new Set(keepExistingSecretFields);
|
|
173
|
+
const result: Record<string, unknown> = {};
|
|
174
|
+
|
|
175
|
+
for (const [key, fieldValue] of Object.entries(value)) {
|
|
176
|
+
const propSchema = properties[key];
|
|
177
|
+
const isBlankKeepExistingSecret =
|
|
178
|
+
propSchema?.["x-secret"] === true &&
|
|
179
|
+
keepExisting.has(key) &&
|
|
180
|
+
isValueEmpty(fieldValue, propSchema);
|
|
181
|
+
if (isBlankKeepExistingSecret) continue;
|
|
182
|
+
result[key] = fieldValue;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
return result;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* List the top-level `x-secret` field keys declared in a schema. The owning
|
|
190
|
+
* page uses this in EDIT mode to mark every secret field as keep-existing
|
|
191
|
+
* (their stored values are redacted out of the loaded preview, so a blank
|
|
192
|
+
* input must mean "keep existing" rather than "clear"). CREATE mode passes
|
|
193
|
+
* an empty list so blank secrets stay genuinely required.
|
|
194
|
+
*/
|
|
195
|
+
export function listSecretFieldKeys(schema: JsonSchema): string[] {
|
|
196
|
+
const properties = schema.properties;
|
|
197
|
+
if (!properties) return [];
|
|
198
|
+
return Object.entries(properties)
|
|
199
|
+
.filter(([, propSchema]) => propSchema["x-secret"] === true)
|
|
200
|
+
.map(([key]) => key);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Derive the human-readable label DynamicForm renders for a field key
|
|
205
|
+
* (capitalized first letter), kept in sync with the label logic in
|
|
206
|
+
* `DynamicForm.tsx` so inline messages read naturally.
|
|
207
|
+
*/
|
|
208
|
+
function labelForKey(key: string): string {
|
|
209
|
+
return key.charAt(0).toUpperCase() + key.slice(1);
|
|
210
|
+
}
|
|
@@ -1,29 +1,46 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { Settings } from "lucide-react";
|
|
3
|
-
import type {
|
|
1
|
+
import { lazy, Suspense } from "react";
|
|
2
|
+
import { Settings, type LucideIcon } from "lucide-react";
|
|
3
|
+
import type { IconName, BrandIconName } from "@checkstack/common";
|
|
4
|
+
import { brandIcons } from "./BrandIcon";
|
|
4
5
|
|
|
5
|
-
// Re-export the
|
|
6
|
-
export type { LucideIconName } from "@checkstack/common";
|
|
6
|
+
// Re-export the icon-name types for convenience.
|
|
7
|
+
export type { IconName, LucideIconName } from "@checkstack/common";
|
|
7
8
|
|
|
8
9
|
/**
|
|
9
10
|
* Props for the DynamicIcon component
|
|
10
11
|
*/
|
|
11
12
|
export interface DynamicIconProps {
|
|
12
|
-
/**
|
|
13
|
-
|
|
13
|
+
/**
|
|
14
|
+
* Icon name. Lucide names are PascalCase (e.g. 'CircleAlert', 'HeartPulse');
|
|
15
|
+
* the few brand names lucide v1 dropped ('Github', 'Gitlab') resolve to
|
|
16
|
+
* vendored marks.
|
|
17
|
+
*/
|
|
18
|
+
name?: IconName;
|
|
14
19
|
/** CSS class name to apply to the icon */
|
|
15
20
|
className?: string;
|
|
16
21
|
/** Fallback icon if name is not provided */
|
|
17
22
|
fallback?: LucideIcon;
|
|
18
23
|
}
|
|
19
24
|
|
|
25
|
+
// The lucide icon set is loaded lazily through `iconRegistry` (see that file):
|
|
26
|
+
// importing lucide's `icons` map eagerly would ship the entire ~1600-icon set
|
|
27
|
+
// in the initial bundle. `RegistryIcon` is the only importer of that map, and
|
|
28
|
+
// it's behind React.lazy, so the icon set is fetched on demand the first time a
|
|
29
|
+
// data-driven icon renders - never in the initial load.
|
|
30
|
+
const RegistryIcon = lazy(() => import("./iconRegistry"));
|
|
31
|
+
|
|
32
|
+
function isBrandIcon(name: string): name is BrandIconName {
|
|
33
|
+
return Object.prototype.hasOwnProperty.call(brandIcons, name);
|
|
34
|
+
}
|
|
35
|
+
|
|
20
36
|
/**
|
|
21
|
-
* Dynamically renders
|
|
22
|
-
*
|
|
37
|
+
* Dynamically renders an icon by name. Lucide icons are code-split into a single
|
|
38
|
+
* on-demand chunk; vendored brand icons render synchronously. Falls back to the
|
|
39
|
+
* Settings icon if no name is provided.
|
|
23
40
|
*
|
|
24
41
|
* @example
|
|
25
|
-
* <DynamicIcon name="
|
|
26
|
-
* <DynamicIcon name="
|
|
42
|
+
* <DynamicIcon name="CircleAlert" />
|
|
43
|
+
* <DynamicIcon name="Github" className="h-6 w-6" />
|
|
27
44
|
*/
|
|
28
45
|
export function DynamicIcon({
|
|
29
46
|
name,
|
|
@@ -34,12 +51,17 @@ export function DynamicIcon({
|
|
|
34
51
|
return <FallbackIcon className={className} />;
|
|
35
52
|
}
|
|
36
53
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
return <FallbackIcon className={className} />;
|
|
54
|
+
// Brand marks lucide v1 removed - render the vendored component synchronously.
|
|
55
|
+
if (isBrandIcon(name)) {
|
|
56
|
+
const Brand = brandIcons[name];
|
|
57
|
+
return <Brand className={className} />;
|
|
42
58
|
}
|
|
43
59
|
|
|
44
|
-
return
|
|
60
|
+
return (
|
|
61
|
+
// Invisible, correctly-sized placeholder while the icon chunk loads, so
|
|
62
|
+
// there's no layout shift and no flash of a wrong (fallback) icon.
|
|
63
|
+
<Suspense fallback={<span aria-hidden className={className} />}>
|
|
64
|
+
<RegistryIcon name={name} className={className} />
|
|
65
|
+
</Suspense>
|
|
66
|
+
);
|
|
45
67
|
}
|