@checkstack/healthcheck-frontend 0.0.3 → 0.2.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.
@@ -0,0 +1,432 @@
1
+ import React, { useMemo } from "react";
2
+ import {
3
+ Button,
4
+ Select,
5
+ SelectContent,
6
+ SelectItem,
7
+ SelectTrigger,
8
+ SelectValue,
9
+ Input,
10
+ } from "@checkstack/ui";
11
+ import type {
12
+ JsonSchemaBase,
13
+ JsonSchemaPropertyBase,
14
+ } from "@checkstack/common";
15
+ import { Plus, Trash2 } from "lucide-react";
16
+
17
+ // ============================================================================
18
+ // TYPES
19
+ // ============================================================================
20
+
21
+ // Use base types for assertion building - works with any JSON Schema
22
+ type JsonSchema = JsonSchemaBase<JsonSchemaPropertyBase>;
23
+ type JsonSchemaProperty = JsonSchemaPropertyBase;
24
+
25
+ type FieldType =
26
+ | "string"
27
+ | "number"
28
+ | "boolean"
29
+ | "enum"
30
+ | "array"
31
+ | "jsonpath";
32
+
33
+ export interface AssertableField {
34
+ path: string;
35
+ displayName: string;
36
+ type: FieldType;
37
+ enumValues?: unknown[];
38
+ /** For jsonpath fields, the original field path (e.g., "body") */
39
+ sourceField?: string;
40
+ }
41
+
42
+ export interface Assertion {
43
+ field: string;
44
+ operator: string;
45
+ value?: unknown;
46
+ /** JSONPath expression for jsonpath-type fields */
47
+ jsonPath?: string;
48
+ }
49
+
50
+ interface AssertionBuilderProps {
51
+ resultSchema: JsonSchema;
52
+ assertions: Assertion[];
53
+ onChange: (assertions: Assertion[]) => void;
54
+ }
55
+
56
+ // ============================================================================
57
+ // OPERATORS BY FIELD TYPE
58
+ // ============================================================================
59
+
60
+ const OPERATORS: Record<FieldType, { value: string; label: string }[]> = {
61
+ string: [
62
+ { value: "equals", label: "Equals" },
63
+ { value: "notEquals", label: "Not Equals" },
64
+ { value: "contains", label: "Contains" },
65
+ { value: "startsWith", label: "Starts With" },
66
+ { value: "endsWith", label: "Ends With" },
67
+ { value: "matches", label: "Matches (Regex)" },
68
+ { value: "isEmpty", label: "Is Empty" },
69
+ ],
70
+ number: [
71
+ { value: "equals", label: "Equals" },
72
+ { value: "notEquals", label: "Not Equals" },
73
+ { value: "lessThan", label: "Less Than" },
74
+ { value: "lessThanOrEqual", label: "Less Than or Equal" },
75
+ { value: "greaterThan", label: "Greater Than" },
76
+ { value: "greaterThanOrEqual", label: "Greater Than or Equal" },
77
+ ],
78
+ boolean: [
79
+ { value: "isTrue", label: "Is True" },
80
+ { value: "isFalse", label: "Is False" },
81
+ ],
82
+ enum: [{ value: "equals", label: "Equals" }],
83
+ array: [
84
+ { value: "exists", label: "Exists" },
85
+ { value: "notExists", label: "Not Exists" },
86
+ ],
87
+ jsonpath: [
88
+ { value: "exists", label: "Exists" },
89
+ { value: "notExists", label: "Not Exists" },
90
+ { value: "equals", label: "Equals" },
91
+ { value: "notEquals", label: "Not Equals" },
92
+ { value: "contains", label: "Contains" },
93
+ { value: "greaterThan", label: "Greater Than" },
94
+ { value: "lessThan", label: "Less Than" },
95
+ ],
96
+ };
97
+
98
+ // Operators that don't need a value input
99
+ const VALUE_LESS_OPERATORS = new Set([
100
+ "isEmpty",
101
+ "isTrue",
102
+ "isFalse",
103
+ "exists",
104
+ "notExists",
105
+ ]);
106
+
107
+ // ============================================================================
108
+ // SCHEMA EXTRACTION
109
+ // ============================================================================
110
+
111
+ /**
112
+ * Extract assertable fields from a JSON Schema, supporting nested objects.
113
+ * Reuses the JsonSchemaProperty type from @checkstack/ui DynamicForm.
114
+ */
115
+ export function extractAssertableFields(
116
+ schema: JsonSchema,
117
+ prefix = ""
118
+ ): AssertableField[] {
119
+ const fields: AssertableField[] = [];
120
+
121
+ if (!schema.properties) return fields;
122
+
123
+ for (const [key, prop] of Object.entries(schema.properties)) {
124
+ const path = prefix ? `${prefix}.${key}` : key;
125
+ const displayName = path
126
+ .split(".")
127
+ .map((p) => p.charAt(0).toUpperCase() + p.slice(1))
128
+ .join(" → ");
129
+
130
+ // Handle enum type
131
+ if (prop.enum && prop.enum.length > 0) {
132
+ fields.push({
133
+ path,
134
+ displayName,
135
+ type: "enum",
136
+ enumValues: prop.enum,
137
+ });
138
+ continue;
139
+ }
140
+
141
+ // Determine type (handle type arrays like ["string", "null"] if present)
142
+ const type = prop.type;
143
+
144
+ switch (type) {
145
+ case "string": {
146
+ // Check for x-jsonpath metadata
147
+ const hasJsonPath =
148
+ (prop as Record<string, unknown>)["x-jsonpath"] === true;
149
+ if (hasJsonPath) {
150
+ fields.push({
151
+ path: `${path}.$`,
152
+ displayName: `${displayName} (JSONPath)`,
153
+ type: "jsonpath",
154
+ sourceField: path,
155
+ });
156
+ }
157
+ fields.push({ path, displayName, type: "string" });
158
+ break;
159
+ }
160
+ case "number":
161
+ case "integer": {
162
+ fields.push({ path, displayName, type: "number" });
163
+ break;
164
+ }
165
+ case "boolean": {
166
+ fields.push({ path, displayName, type: "boolean" });
167
+ break;
168
+ }
169
+ case "array": {
170
+ // Add array-level assertions
171
+ fields.push({ path, displayName, type: "array" });
172
+ // If array has typed items with properties, add item-level fields
173
+ if (
174
+ prop.items &&
175
+ typeof prop.items === "object" &&
176
+ "properties" in prop.items
177
+ ) {
178
+ const itemsWithProps = prop.items as JsonSchemaProperty;
179
+ if (itemsWithProps.properties) {
180
+ const itemFields = extractAssertableFields(
181
+ { properties: itemsWithProps.properties },
182
+ `${path}[*]`
183
+ );
184
+ fields.push(...itemFields);
185
+ }
186
+ }
187
+ break;
188
+ }
189
+ case "object": {
190
+ // Recurse into nested objects
191
+ if (prop.properties) {
192
+ const nestedFields = extractAssertableFields(
193
+ { properties: prop.properties },
194
+ path
195
+ );
196
+ fields.push(...nestedFields);
197
+ }
198
+ break;
199
+ }
200
+ default: {
201
+ // Unknown type - treat as string for flexibility
202
+ if (type) {
203
+ fields.push({ path, displayName, type: "string" });
204
+ }
205
+ }
206
+ }
207
+ }
208
+
209
+ return fields;
210
+ }
211
+
212
+ // ============================================================================
213
+ // COMPONENT
214
+ // ============================================================================
215
+
216
+ /**
217
+ * AssertionBuilder component for creating assertions based on JSON Schema.
218
+ * Automatically derives available fields and operators from the result schema.
219
+ */
220
+ export const AssertionBuilder: React.FC<AssertionBuilderProps> = ({
221
+ resultSchema,
222
+ assertions,
223
+ onChange,
224
+ }) => {
225
+ // Extract assertable fields from schema
226
+ const fields = useMemo(
227
+ () => extractAssertableFields(resultSchema),
228
+ [resultSchema]
229
+ );
230
+
231
+ const getFieldByPath = (path: string) => {
232
+ return fields.find((f) => f.path === path);
233
+ };
234
+
235
+ const handleAddAssertion = () => {
236
+ if (fields.length === 0) return;
237
+
238
+ const firstField = fields[0];
239
+ const operators = OPERATORS[firstField.type];
240
+
241
+ onChange([
242
+ ...assertions,
243
+ {
244
+ field: firstField.path,
245
+ operator: operators[0].value,
246
+ value: undefined,
247
+ },
248
+ ]);
249
+ };
250
+
251
+ const handleRemoveAssertion = (index: number) => {
252
+ const updated = [...assertions];
253
+ updated.splice(index, 1);
254
+ onChange(updated);
255
+ };
256
+
257
+ const handleFieldChange = (index: number, fieldPath: string) => {
258
+ const field = getFieldByPath(fieldPath);
259
+ if (!field) return;
260
+
261
+ const operators = OPERATORS[field.type];
262
+ const updated = [...assertions];
263
+ updated[index] = {
264
+ field: fieldPath,
265
+ operator: operators[0].value,
266
+ value: undefined,
267
+ };
268
+ onChange(updated);
269
+ };
270
+
271
+ const handleOperatorChange = (index: number, operator: string) => {
272
+ const updated = [...assertions];
273
+ updated[index] = { ...updated[index], operator };
274
+ // Clear value if operator doesn't need one
275
+ if (VALUE_LESS_OPERATORS.has(operator)) {
276
+ updated[index].value = undefined;
277
+ }
278
+ onChange(updated);
279
+ };
280
+
281
+ const handleValueChange = (index: number, value: unknown) => {
282
+ const updated = [...assertions];
283
+ updated[index] = { ...updated[index], value };
284
+ onChange(updated);
285
+ };
286
+
287
+ const handleJsonPathChange = (index: number, jsonPath: string) => {
288
+ const updated = [...assertions];
289
+ updated[index] = { ...updated[index], jsonPath };
290
+ onChange(updated);
291
+ };
292
+
293
+ if (fields.length === 0) {
294
+ return (
295
+ <div className="text-muted-foreground text-sm">
296
+ No assertable fields available in result schema.
297
+ </div>
298
+ );
299
+ }
300
+
301
+ return (
302
+ <div className="space-y-4">
303
+ {assertions.map((assertion, index) => {
304
+ const field = getFieldByPath(assertion.field);
305
+ // Safely get operators with fallback to empty array
306
+ const operators = field ? OPERATORS[field.type] ?? [] : [];
307
+ const needsValue = !VALUE_LESS_OPERATORS.has(assertion.operator);
308
+
309
+ // Check if current values match available options (prevents Radix UI crash)
310
+ const fieldValueValid = fields.some((f) => f.path === assertion.field);
311
+ const operatorValueValid = operators.some(
312
+ (op) => op.value === assertion.operator
313
+ );
314
+
315
+ return (
316
+ <div key={index} className="flex items-start gap-2 flex-wrap">
317
+ {/* Field selector */}
318
+ <div className="flex-1 min-w-[120px]">
319
+ <Select
320
+ value={fieldValueValid ? assertion.field : undefined}
321
+ onValueChange={(v) => handleFieldChange(index, v)}
322
+ >
323
+ <SelectTrigger>
324
+ <SelectValue placeholder="Select field..." />
325
+ </SelectTrigger>
326
+ <SelectContent>
327
+ {fields.map((f) => (
328
+ <SelectItem key={f.path} value={f.path}>
329
+ {f.displayName}
330
+ </SelectItem>
331
+ ))}
332
+ </SelectContent>
333
+ </Select>
334
+ </div>
335
+
336
+ {/* JSONPath input (for jsonpath fields) */}
337
+ {field?.type === "jsonpath" && (
338
+ <div className="flex-1 min-w-[120px]">
339
+ <Input
340
+ value={assertion.jsonPath ?? ""}
341
+ onChange={(e) => handleJsonPathChange(index, e.target.value)}
342
+ placeholder="$.path.to.value"
343
+ />
344
+ </div>
345
+ )}
346
+
347
+ {/* Operator selector */}
348
+ <div className="flex-1 min-w-[100px]">
349
+ <Select
350
+ value={operatorValueValid ? assertion.operator : undefined}
351
+ onValueChange={(v) => handleOperatorChange(index, v)}
352
+ >
353
+ <SelectTrigger>
354
+ <SelectValue placeholder="Select operator..." />
355
+ </SelectTrigger>
356
+ <SelectContent>
357
+ {operators.map((op) => (
358
+ <SelectItem key={op.value} value={op.value}>
359
+ {op.label}
360
+ </SelectItem>
361
+ ))}
362
+ </SelectContent>
363
+ </Select>
364
+ </div>
365
+
366
+ {/* Value input */}
367
+ {needsValue && (
368
+ <div className="flex-1 min-w-[120px]">
369
+ {field?.type === "enum" && field.enumValues ? (
370
+ <Select
371
+ value={String(assertion.value ?? "")}
372
+ onValueChange={(v) => handleValueChange(index, v)}
373
+ >
374
+ <SelectTrigger>
375
+ <SelectValue placeholder="Select value..." />
376
+ </SelectTrigger>
377
+ <SelectContent>
378
+ {field.enumValues.map((v) => (
379
+ <SelectItem key={String(v)} value={String(v)}>
380
+ {String(v)}
381
+ </SelectItem>
382
+ ))}
383
+ </SelectContent>
384
+ </Select>
385
+ ) : field?.type === "number" ? (
386
+ <Input
387
+ type="number"
388
+ value={(assertion.value as number) ?? ""}
389
+ onChange={(e) =>
390
+ handleValueChange(
391
+ index,
392
+ e.target.value ? Number(e.target.value) : undefined
393
+ )
394
+ }
395
+ placeholder="Value"
396
+ />
397
+ ) : (
398
+ <Input
399
+ value={String(assertion.value ?? "")}
400
+ onChange={(e) => handleValueChange(index, e.target.value)}
401
+ placeholder="Value"
402
+ />
403
+ )}
404
+ </div>
405
+ )}
406
+
407
+ {/* Remove button */}
408
+ <Button
409
+ variant="ghost"
410
+ size="icon"
411
+ className="text-destructive hover:text-destructive"
412
+ onClick={() => handleRemoveAssertion(index)}
413
+ >
414
+ <Trash2 className="h-4 w-4" />
415
+ </Button>
416
+ </div>
417
+ );
418
+ })}
419
+
420
+ {/* Add assertion button */}
421
+ <Button
422
+ type="button"
423
+ variant="outline"
424
+ size="sm"
425
+ onClick={handleAddAssertion}
426
+ >
427
+ <Plus className="h-4 w-4 mr-2" />
428
+ Add Assertion
429
+ </Button>
430
+ </div>
431
+ );
432
+ };