@checkstack/ui 0.2.4 → 0.3.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.
Files changed (29) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/package.json +10 -3
  3. package/src/components/CodeEditor/CodeEditor.tsx +420 -0
  4. package/src/components/CodeEditor/index.ts +10 -0
  5. package/src/components/CodeEditor/languageSupport/enterBehavior.test.ts +173 -0
  6. package/src/components/CodeEditor/languageSupport/enterBehavior.ts +35 -0
  7. package/src/components/CodeEditor/languageSupport/index.ts +22 -0
  8. package/src/components/CodeEditor/languageSupport/json.test.ts +271 -0
  9. package/src/components/CodeEditor/languageSupport/json.ts +240 -0
  10. package/src/components/CodeEditor/languageSupport/markdown.test.ts +245 -0
  11. package/src/components/CodeEditor/languageSupport/markdown.ts +183 -0
  12. package/src/components/CodeEditor/languageSupport/types.ts +48 -0
  13. package/src/components/CodeEditor/languageSupport/xml.test.ts +236 -0
  14. package/src/components/CodeEditor/languageSupport/xml.ts +194 -0
  15. package/src/components/CodeEditor/languageSupport/yaml.test.ts +200 -0
  16. package/src/components/CodeEditor/languageSupport/yaml.ts +205 -0
  17. package/src/components/DynamicForm/DynamicForm.tsx +2 -24
  18. package/src/components/DynamicForm/DynamicOptionsField.tsx +48 -21
  19. package/src/components/DynamicForm/FormField.tsx +38 -70
  20. package/src/components/DynamicForm/JsonField.tsx +19 -25
  21. package/src/components/DynamicForm/KeyValueEditor.tsx +314 -0
  22. package/src/components/DynamicForm/MultiTypeEditorField.tsx +465 -0
  23. package/src/components/DynamicForm/index.ts +13 -0
  24. package/src/components/DynamicForm/types.ts +14 -8
  25. package/src/components/DynamicForm/utils.test.ts +390 -0
  26. package/src/components/DynamicForm/utils.ts +142 -3
  27. package/src/index.ts +1 -1
  28. package/src/components/TemplateEditor.test.ts +0 -156
  29. package/src/components/TemplateEditor.tsx +0 -435
@@ -1,156 +0,0 @@
1
- import { describe, expect, it } from "bun:test";
2
- import { detectTemplateContext } from "./TemplateEditor";
3
-
4
- describe("detectTemplateContext", () => {
5
- describe("mustache syntax ({{}})", () => {
6
- it("should detect context when cursor is immediately after {{", () => {
7
- const result = detectTemplateContext("Hello {{", 8, "mustache");
8
- expect(result).toEqual({
9
- isInTemplate: true,
10
- query: "",
11
- startPos: 6,
12
- });
13
- });
14
-
15
- it("should detect context with partial query", () => {
16
- const result = detectTemplateContext("Hello {{pay", 11, "mustache");
17
- expect(result).toEqual({
18
- isInTemplate: true,
19
- query: "pay",
20
- startPos: 6,
21
- });
22
- });
23
-
24
- it("should detect context with full path being typed", () => {
25
- const result = detectTemplateContext(
26
- "Hello {{payload.title",
27
- 21,
28
- "mustache"
29
- );
30
- expect(result).toEqual({
31
- isInTemplate: true,
32
- query: "payload.title",
33
- startPos: 6,
34
- });
35
- });
36
-
37
- it("should not detect context when template is closed", () => {
38
- const result = detectTemplateContext(
39
- "Hello {{name}} world",
40
- 20,
41
- "mustache"
42
- );
43
- expect(result).toEqual({
44
- isInTemplate: false,
45
- query: "",
46
- startPos: -1,
47
- });
48
- });
49
-
50
- it("should not detect context when cursor is before {{", () => {
51
- const result = detectTemplateContext("Hello {{name}}", 3, "mustache");
52
- expect(result).toEqual({
53
- isInTemplate: false,
54
- query: "",
55
- startPos: -1,
56
- });
57
- });
58
-
59
- it("should detect context in second template when first is closed", () => {
60
- const result = detectTemplateContext(
61
- "{{first}} and {{second",
62
- 22,
63
- "mustache"
64
- );
65
- expect(result).toEqual({
66
- isInTemplate: true,
67
- query: "second",
68
- startPos: 14,
69
- });
70
- });
71
-
72
- it("should not detect context when query contains newline", () => {
73
- const result = detectTemplateContext("Hello {{\nworld", 14, "mustache");
74
- expect(result).toEqual({
75
- isInTemplate: false,
76
- query: "",
77
- startPos: -1,
78
- });
79
- });
80
-
81
- it("should handle empty string", () => {
82
- const result = detectTemplateContext("", 0, "mustache");
83
- expect(result).toEqual({
84
- isInTemplate: false,
85
- query: "",
86
- startPos: -1,
87
- });
88
- });
89
-
90
- it("should handle text without any templates", () => {
91
- const result = detectTemplateContext("Hello world", 11, "mustache");
92
- expect(result).toEqual({
93
- isInTemplate: false,
94
- query: "",
95
- startPos: -1,
96
- });
97
- });
98
- });
99
-
100
- describe("dollar syntax (${})", () => {
101
- it("should detect context when cursor is immediately after ${", () => {
102
- const result = detectTemplateContext("Hello ${", 8, "dollar");
103
- expect(result).toEqual({
104
- isInTemplate: true,
105
- query: "",
106
- startPos: 6,
107
- });
108
- });
109
-
110
- it("should detect context with partial query", () => {
111
- const result = detectTemplateContext("Hello ${user", 12, "dollar");
112
- expect(result).toEqual({
113
- isInTemplate: true,
114
- query: "user",
115
- startPos: 6,
116
- });
117
- });
118
-
119
- it("should not detect context when template is closed", () => {
120
- const result = detectTemplateContext("Hello ${name} world", 19, "dollar");
121
- expect(result).toEqual({
122
- isInTemplate: false,
123
- query: "",
124
- startPos: -1,
125
- });
126
- });
127
- });
128
-
129
- describe("edge cases", () => {
130
- it("should handle nested braces correctly", () => {
131
- const result = detectTemplateContext("{{outer.{{inner", 15, "mustache");
132
- // Should find the last {{ which is at position 8
133
- expect(result.isInTemplate).toBe(true);
134
- expect(result.startPos).toBe(8);
135
- expect(result.query).toBe("inner");
136
- });
137
-
138
- it("should handle cursor at start of template", () => {
139
- const result = detectTemplateContext("{{", 2, "mustache");
140
- expect(result).toEqual({
141
- isInTemplate: true,
142
- query: "",
143
- startPos: 0,
144
- });
145
- });
146
-
147
- it("should detect context after multiple closed templates", () => {
148
- const result = detectTemplateContext("{{a}} {{b}} {{c", 15, "mustache");
149
- expect(result).toEqual({
150
- isInTemplate: true,
151
- query: "c",
152
- startPos: 12,
153
- });
154
- });
155
- });
156
- });
@@ -1,435 +0,0 @@
1
- import * as React from "react";
2
- import { cn } from "../utils";
3
-
4
- /**
5
- * A single payload property available for templating
6
- */
7
- export interface TemplateProperty {
8
- /** Full path to the property, e.g., "payload.incident.title" */
9
- path: string;
10
- /** Type of the property, e.g., "string", "number", "boolean" */
11
- type: string;
12
- /** Optional description of the property */
13
- description?: string;
14
- }
15
-
16
- export interface TemplateEditorProps {
17
- /** Current template value */
18
- value: string;
19
- /** Change handler */
20
- onChange: (value: string) => void;
21
- /** Available properties for hints */
22
- availableProperties?: TemplateProperty[];
23
- /** Placeholder text */
24
- placeholder?: string;
25
- /** Number of rows */
26
- rows?: number;
27
- /** Additional class name */
28
- className?: string;
29
- /** Disabled state */
30
- disabled?: boolean;
31
- /** Template syntax hint, e.g., "{{" */
32
- templateSyntax?: "mustache" | "dollar";
33
- }
34
-
35
- /**
36
- * Get display type color based on property type
37
- */
38
- const getTypeColor = (type: string) => {
39
- switch (type.toLowerCase()) {
40
- case "string": {
41
- return "text-green-600 dark:text-green-400";
42
- }
43
- case "number":
44
- case "integer": {
45
- return "text-blue-600 dark:text-blue-400";
46
- }
47
- case "boolean": {
48
- return "text-purple-600 dark:text-purple-400";
49
- }
50
- case "array": {
51
- return "text-orange-600 dark:text-orange-400";
52
- }
53
- case "object": {
54
- return "text-yellow-600 dark:text-yellow-400";
55
- }
56
- default: {
57
- return "text-muted-foreground";
58
- }
59
- }
60
- };
61
-
62
- /**
63
- * Detects if the cursor is inside an unclosed template context (after {{ without matching }})
64
- * Returns the query text being typed and the position where {{ starts
65
- */
66
- export const detectTemplateContext = (
67
- value: string,
68
- cursorPos: number,
69
- syntax: "mustache" | "dollar" = "mustache"
70
- ): { isInTemplate: boolean; query: string; startPos: number } => {
71
- const openToken = syntax === "mustache" ? "{{" : "${";
72
- const closeToken = syntax === "mustache" ? "}}" : "}";
73
-
74
- const textBeforeCursor = value.slice(0, cursorPos);
75
- const lastOpenBrace = textBeforeCursor.lastIndexOf(openToken);
76
- const lastCloseBrace = textBeforeCursor.lastIndexOf(closeToken);
77
-
78
- // If we're after {{ and no }} follows before cursor, we're in a template context
79
- if (lastOpenBrace !== -1 && lastOpenBrace > lastCloseBrace) {
80
- const query = textBeforeCursor.slice(lastOpenBrace + openToken.length);
81
- // Only show popup if query doesn't contain newlines (single line context)
82
- if (!query.includes("\n")) {
83
- return { isInTemplate: true, query, startPos: lastOpenBrace };
84
- }
85
- }
86
-
87
- return { isInTemplate: false, query: "", startPos: -1 };
88
- };
89
-
90
- /**
91
- * Filter properties based on search query (fuzzy match on path)
92
- */
93
- const filterProperties = (
94
- properties: TemplateProperty[],
95
- query: string
96
- ): TemplateProperty[] => {
97
- if (!query.trim()) return properties;
98
- const lowerQuery = query.toLowerCase();
99
- return properties.filter((prop) =>
100
- prop.path.toLowerCase().includes(lowerQuery)
101
- );
102
- };
103
-
104
- /**
105
- * TemplateEditor - A textarea with autocomplete for template properties.
106
- *
107
- * Shows an autocomplete popup when the user types "{{" with available
108
- * template properties. Supports keyboard navigation and filtering.
109
- */
110
- export const TemplateEditor = React.forwardRef<
111
- HTMLTextAreaElement,
112
- TemplateEditorProps
113
- >(
114
- (
115
- {
116
- value,
117
- onChange,
118
- availableProperties = [],
119
- placeholder = "Enter template...",
120
- rows = 4,
121
- className,
122
- disabled = false,
123
- templateSyntax = "mustache",
124
- },
125
- ref
126
- ) => {
127
- const textareaRef = React.useRef<HTMLTextAreaElement | null>(null);
128
- const containerRef = React.useRef<HTMLDivElement | null>(null);
129
- const popupRef = React.useRef<HTMLDivElement | null>(null);
130
-
131
- // Autocomplete state
132
- const [showPopup, setShowPopup] = React.useState(false);
133
- const [popupPosition, setPopupPosition] = React.useState({
134
- top: 0,
135
- left: 0,
136
- });
137
- const [selectedIndex, setSelectedIndex] = React.useState(0);
138
- const [templateContext, setTemplateContext] = React.useState<{
139
- query: string;
140
- startPos: number;
141
- }>({ query: "", startPos: -1 });
142
-
143
- // Filtered properties based on query
144
- const filteredProperties = React.useMemo(
145
- () => filterProperties(availableProperties, templateContext.query),
146
- [availableProperties, templateContext.query]
147
- );
148
-
149
- // Merge refs
150
- React.useImperativeHandle(ref, () => textareaRef.current!);
151
-
152
- /**
153
- * Calculate popup position based on cursor location in textarea
154
- */
155
- const calculatePopupPosition = React.useCallback(() => {
156
- const textarea = textareaRef.current;
157
- const container = containerRef.current;
158
- if (!textarea || !container) return;
159
-
160
- // Get textarea position relative to container
161
- const textareaRect = textarea.getBoundingClientRect();
162
- const containerRect = container.getBoundingClientRect();
163
-
164
- // Simple positioning: below the textarea with fixed offset
165
- // For more accurate cursor-based positioning, we'd need to measure text
166
- const lineHeight =
167
- Number.parseInt(getComputedStyle(textarea).lineHeight) || 20;
168
- const paddingTop =
169
- Number.parseInt(getComputedStyle(textarea).paddingTop) || 8;
170
-
171
- // Count newlines before cursor to estimate vertical position
172
- const cursorPos = textarea.selectionStart ?? 0;
173
- const textBeforeCursor = value.slice(0, cursorPos);
174
- const lines = textBeforeCursor.split("\n");
175
- const currentLineIndex = lines.length - 1;
176
-
177
- // Position popup below current line
178
- const top =
179
- textareaRect.top -
180
- containerRect.top +
181
- paddingTop +
182
- (currentLineIndex + 1) * lineHeight;
183
-
184
- // Estimate horizontal position based on current line length
185
- const currentLine = lines[currentLineIndex] ?? "";
186
- const charWidth = 8; // Approximate monospace char width
187
- const paddingLeft =
188
- Number.parseInt(getComputedStyle(textarea).paddingLeft) || 12;
189
- const left = Math.min(
190
- paddingLeft + currentLine.length * charWidth,
191
- textareaRect.width - 200 // Keep popup within textarea bounds
192
- );
193
-
194
- setPopupPosition({
195
- top: Math.max(top, lineHeight),
196
- left: Math.max(left, 0),
197
- });
198
- }, [value]);
199
-
200
- /**
201
- * Handle text input and detect template context
202
- */
203
- const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
204
- const newValue = e.target.value;
205
- onChange(newValue);
206
-
207
- // Check template context after state update
208
- setTimeout(() => {
209
- const textarea = textareaRef.current;
210
- if (!textarea) return;
211
-
212
- const cursorPos = textarea.selectionStart ?? 0;
213
- const context = detectTemplateContext(
214
- newValue,
215
- cursorPos,
216
- templateSyntax
217
- );
218
-
219
- if (context.isInTemplate && availableProperties.length > 0) {
220
- setTemplateContext({
221
- query: context.query,
222
- startPos: context.startPos,
223
- });
224
- setShowPopup(true);
225
- setSelectedIndex(0);
226
- calculatePopupPosition();
227
- } else {
228
- setShowPopup(false);
229
- }
230
- }, 0);
231
- };
232
-
233
- /**
234
- * Handle cursor position changes (click, arrow keys in text)
235
- */
236
- const handleSelect = () => {
237
- const textarea = textareaRef.current;
238
- if (!textarea || disabled) return;
239
-
240
- const cursorPos = textarea.selectionStart ?? 0;
241
- const context = detectTemplateContext(value, cursorPos, templateSyntax);
242
-
243
- if (context.isInTemplate && availableProperties.length > 0) {
244
- setTemplateContext({
245
- query: context.query,
246
- startPos: context.startPos,
247
- });
248
- setShowPopup(true);
249
- calculatePopupPosition();
250
- } else {
251
- setShowPopup(false);
252
- }
253
- };
254
-
255
- /**
256
- * Insert selected property at the template context position
257
- */
258
- const insertProperty = React.useCallback(
259
- (prop: TemplateProperty) => {
260
- const textarea = textareaRef.current;
261
- if (!textarea || disabled || templateContext.startPos === -1) return;
262
-
263
- const openToken = templateSyntax === "mustache" ? "{{" : "${";
264
- const closeToken = templateSyntax === "mustache" ? "}}" : "}";
265
- const template = `${openToken}${prop.path}${closeToken}`;
266
-
267
- // Replace from {{ to cursor with the complete template
268
- const cursorPos = textarea.selectionStart ?? 0;
269
- const newValue =
270
- value.slice(0, templateContext.startPos) +
271
- template +
272
- value.slice(cursorPos);
273
-
274
- onChange(newValue);
275
- setShowPopup(false);
276
-
277
- // Restore focus and move cursor after inserted text
278
- const newPosition = templateContext.startPos + template.length;
279
- setTimeout(() => {
280
- textarea.focus();
281
- textarea.setSelectionRange(newPosition, newPosition);
282
- }, 0);
283
- },
284
- [value, onChange, templateContext.startPos, templateSyntax, disabled]
285
- );
286
-
287
- /**
288
- * Handle keyboard navigation in popup
289
- */
290
- const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
291
- if (!showPopup || filteredProperties.length === 0) return;
292
-
293
- switch (e.key) {
294
- case "ArrowDown": {
295
- e.preventDefault();
296
- setSelectedIndex((prev) =>
297
- prev < filteredProperties.length - 1 ? prev + 1 : 0
298
- );
299
- break;
300
- }
301
- case "ArrowUp": {
302
- e.preventDefault();
303
- setSelectedIndex((prev) =>
304
- prev > 0 ? prev - 1 : filteredProperties.length - 1
305
- );
306
- break;
307
- }
308
- case "Enter":
309
- case "Tab": {
310
- e.preventDefault();
311
- const selected = filteredProperties[selectedIndex];
312
- if (selected) {
313
- insertProperty(selected);
314
- }
315
- break;
316
- }
317
- case "Escape": {
318
- e.preventDefault();
319
- setShowPopup(false);
320
- break;
321
- }
322
- }
323
- };
324
-
325
- /**
326
- * Close popup when clicking outside
327
- */
328
- React.useEffect(() => {
329
- const handleClickOutside = (event: MouseEvent) => {
330
- if (
331
- popupRef.current &&
332
- !popupRef.current.contains(event.target as Node) &&
333
- textareaRef.current &&
334
- !textareaRef.current.contains(event.target as Node)
335
- ) {
336
- setShowPopup(false);
337
- }
338
- };
339
-
340
- if (showPopup) {
341
- document.addEventListener("mousedown", handleClickOutside);
342
- return () =>
343
- document.removeEventListener("mousedown", handleClickOutside);
344
- }
345
- }, [showPopup]);
346
-
347
- // Reset selected index when filtered properties change
348
- React.useEffect(() => {
349
- setSelectedIndex(0);
350
- }, [filteredProperties.length]);
351
-
352
- // Scroll selected item into view
353
- React.useEffect(() => {
354
- if (!showPopup || !popupRef.current) return;
355
- const selectedElement = popupRef.current.querySelector(
356
- `[data-index="${selectedIndex}"]`
357
- );
358
- selectedElement?.scrollIntoView({ block: "nearest" });
359
- }, [selectedIndex, showPopup]);
360
-
361
- return (
362
- <div ref={containerRef} className={cn("relative", className)}>
363
- {/* Textarea */}
364
- <textarea
365
- ref={textareaRef}
366
- value={value}
367
- onChange={handleChange}
368
- onSelect={handleSelect}
369
- onKeyDown={handleKeyDown}
370
- placeholder={placeholder}
371
- rows={rows}
372
- disabled={disabled}
373
- className={cn(
374
- "flex w-full rounded-md border border-input bg-background px-3 py-2 text-sm font-mono",
375
- "placeholder:text-muted-foreground",
376
- "focus:outline-none focus:ring-2 focus:ring-ring focus:border-transparent",
377
- "disabled:cursor-not-allowed disabled:opacity-50",
378
- "resize-y min-h-[80px]"
379
- )}
380
- />
381
-
382
- {/* Autocomplete popup */}
383
- {showPopup && filteredProperties.length > 0 && (
384
- <div
385
- ref={popupRef}
386
- className={cn(
387
- "absolute z-50 w-72 max-h-48 overflow-y-auto",
388
- "rounded-md border border-border bg-popover shadow-lg"
389
- )}
390
- style={{ top: popupPosition.top, left: popupPosition.left }}
391
- >
392
- <div className="p-1">
393
- {filteredProperties.map((prop, index) => (
394
- <button
395
- key={prop.path}
396
- type="button"
397
- data-index={index}
398
- onClick={() => insertProperty(prop)}
399
- className={cn(
400
- "w-full flex items-center justify-between gap-2 px-2 py-1.5 text-xs rounded-sm text-left",
401
- "hover:bg-accent hover:text-accent-foreground transition-colors",
402
- index === selectedIndex &&
403
- "bg-accent text-accent-foreground"
404
- )}
405
- >
406
- <code className="font-mono truncate">{prop.path}</code>
407
- <span
408
- className={cn(
409
- "font-normal shrink-0",
410
- getTypeColor(prop.type)
411
- )}
412
- >
413
- {prop.type}
414
- </span>
415
- </button>
416
- ))}
417
- </div>
418
- </div>
419
- )}
420
-
421
- {/* Hint text when no popup shown */}
422
- {availableProperties.length > 0 && !showPopup && (
423
- <p className="text-xs text-muted-foreground mt-1.5">
424
- Type{" "}
425
- <code className="px-1 py-0.5 bg-muted rounded text-[10px]">
426
- {templateSyntax === "mustache" ? "{{" : "${"}
427
- </code>{" "}
428
- for template suggestions
429
- </p>
430
- )}
431
- </div>
432
- );
433
- }
434
- );
435
- TemplateEditor.displayName = "TemplateEditor";