@checkstack/ui 1.12.0 → 1.13.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/CHANGELOG.md +154 -0
  2. package/package.json +24 -19
  3. package/src/components/Accordion.tsx +17 -9
  4. package/src/components/ActionCard.tsx +4 -4
  5. package/src/components/BrandIcon.tsx +57 -0
  6. package/src/components/CodeEditor/CodeEditor.tsx +71 -7
  7. package/src/components/CodeEditor/TypefoxEditor.tsx +266 -53
  8. package/src/components/CodeEditor/editorTheme.test.ts +41 -0
  9. package/src/components/CodeEditor/editorTheme.ts +26 -0
  10. package/src/components/CodeEditor/index.ts +3 -1
  11. package/src/components/CodeEditor/monacoGuard.ts +76 -0
  12. package/src/components/CodeEditor/monacoTsService.ts +5 -37
  13. package/src/components/CodeEditor/scriptContext.test.ts +15 -7
  14. package/src/components/CodeEditor/scriptContext.ts +12 -18
  15. package/src/components/CodeEditor/types.ts +20 -0
  16. package/src/components/CodeEditor/validateScripts.ts +53 -13
  17. package/src/components/CodeEditor/vscodeServicesSignal.ts +72 -0
  18. package/src/components/ConfirmationModal.tsx +7 -1
  19. package/src/components/DynamicForm/DynamicForm.tsx +101 -53
  20. package/src/components/DynamicForm/DynamicOptionsField.tsx +19 -14
  21. package/src/components/DynamicForm/FormField.tsx +84 -24
  22. package/src/components/DynamicForm/MultiTypeEditorField.tsx +11 -0
  23. package/src/components/DynamicForm/index.ts +14 -0
  24. package/src/components/DynamicForm/types.ts +63 -1
  25. package/src/components/DynamicForm/utils.test.ts +38 -0
  26. package/src/components/DynamicForm/utils.ts +22 -0
  27. package/src/components/DynamicForm/validation.logic.test.ts +255 -0
  28. package/src/components/DynamicForm/validation.logic.ts +210 -0
  29. package/src/components/DynamicIcon.tsx +39 -17
  30. package/src/components/Markdown.tsx +68 -2
  31. package/src/components/Spinner.tsx +56 -0
  32. package/src/components/StatusBadge.tsx +78 -0
  33. package/src/components/StrategyConfigCard.tsx +3 -3
  34. package/src/components/Tabs.tsx +7 -1
  35. package/src/components/UserMenu.logic.test.ts +37 -0
  36. package/src/components/UserMenu.logic.ts +30 -0
  37. package/src/components/UserMenu.tsx +40 -12
  38. package/src/components/iconRegistry.tsx +27 -0
  39. package/src/index.ts +3 -0
  40. package/stories/Introduction.mdx +1 -1
  41. package/stories/Markdown.stories.tsx +56 -0
  42. package/stories/Spinner.stories.tsx +90 -0
  43. package/tsconfig.json +3 -0
@@ -5,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 { icons, type LucideIcon } from "lucide-react";
2
- import { Settings } from "lucide-react";
3
- import type { LucideIconName } from "@checkstack/common";
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 type for convenience
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
- /** Lucide icon name in PascalCase (e.g., 'AlertCircle', 'HeartPulse') */
13
- name?: LucideIconName;
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 a Lucide icon by name.
22
- * Falls back to Settings icon if the icon name is not provided.
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="AlertCircle" />
26
- * <DynamicIcon name="HeartPulse" className="h-6 w-6" />
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
- const Icon = icons[name];
38
-
39
- // Fallback if icon name doesn't exist in lucide-react
40
- if (!Icon) {
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 <Icon className={className} />;
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
  }