@checkstack/ui 0.2.4 → 0.3.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 (33) hide show
  1. package/CHANGELOG.md +38 -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-utils.ts +117 -0
  9. package/src/components/CodeEditor/languageSupport/json.test.ts +274 -0
  10. package/src/components/CodeEditor/languageSupport/json.ts +139 -0
  11. package/src/components/CodeEditor/languageSupport/markdown-utils.ts +65 -0
  12. package/src/components/CodeEditor/languageSupport/markdown.test.ts +245 -0
  13. package/src/components/CodeEditor/languageSupport/markdown.ts +134 -0
  14. package/src/components/CodeEditor/languageSupport/types.ts +48 -0
  15. package/src/components/CodeEditor/languageSupport/xml-utils.ts +94 -0
  16. package/src/components/CodeEditor/languageSupport/xml.test.ts +239 -0
  17. package/src/components/CodeEditor/languageSupport/xml.ts +116 -0
  18. package/src/components/CodeEditor/languageSupport/yaml-utils.ts +101 -0
  19. package/src/components/CodeEditor/languageSupport/yaml.test.ts +203 -0
  20. package/src/components/CodeEditor/languageSupport/yaml.ts +120 -0
  21. package/src/components/DynamicForm/DynamicForm.tsx +2 -24
  22. package/src/components/DynamicForm/DynamicOptionsField.tsx +48 -21
  23. package/src/components/DynamicForm/FormField.tsx +38 -70
  24. package/src/components/DynamicForm/JsonField.tsx +19 -25
  25. package/src/components/DynamicForm/KeyValueEditor.tsx +314 -0
  26. package/src/components/DynamicForm/MultiTypeEditorField.tsx +465 -0
  27. package/src/components/DynamicForm/index.ts +13 -0
  28. package/src/components/DynamicForm/types.ts +14 -8
  29. package/src/components/DynamicForm/utils.test.ts +390 -0
  30. package/src/components/DynamicForm/utils.ts +142 -3
  31. package/src/index.ts +1 -1
  32. package/src/components/TemplateEditor.test.ts +0 -156
  33. package/src/components/TemplateEditor.tsx +0 -435
@@ -0,0 +1,465 @@
1
+ import React from "react";
2
+ import { Label } from "../Label";
3
+ import { Textarea } from "../Textarea";
4
+ import { CodeEditor, type TemplateProperty } from "../CodeEditor";
5
+ import {
6
+ Select,
7
+ SelectContent,
8
+ SelectItem,
9
+ SelectTrigger,
10
+ SelectValue,
11
+ } from "../Select";
12
+ import { KeyValueEditor } from "./KeyValueEditor";
13
+ import {
14
+ type EditorType,
15
+ detectEditorType,
16
+ serializeFormData,
17
+ parseFormData,
18
+ EDITOR_TYPE_LABELS,
19
+ } from "./utils";
20
+
21
+ export interface MultiTypeEditorFieldProps {
22
+ /** Unique identifier for the field */
23
+ id: string;
24
+ /** Label for the field */
25
+ label: string;
26
+ /** Optional description */
27
+ description?: string;
28
+ /** Current string value */
29
+ value: string | undefined;
30
+ /** Whether the field is required */
31
+ isRequired?: boolean;
32
+ /** Available editor types */
33
+ editorTypes: EditorType[];
34
+ /** Optional template properties for autocomplete */
35
+ templateProperties?: TemplateProperty[];
36
+ /** Callback when value changes */
37
+ onChange: (value: string | undefined) => void;
38
+ }
39
+
40
+ /**
41
+ * A multi-type editor field that lets users select from different input modes.
42
+ * Supports raw text, JSON, form data, and "none" (disabled).
43
+ * When templateProperties are provided, all applicable modes get template autocomplete.
44
+ */
45
+ export const MultiTypeEditorField: React.FC<MultiTypeEditorFieldProps> = ({
46
+ id,
47
+ label,
48
+ description,
49
+ value,
50
+ isRequired,
51
+ editorTypes,
52
+ templateProperties,
53
+ onChange,
54
+ }) => {
55
+ // Detect initial editor type from value
56
+ const [selectedType, setSelectedType] = React.useState<EditorType>(() =>
57
+ detectEditorType(value, editorTypes),
58
+ );
59
+
60
+ // Track if this is the initial mount to avoid re-detecting on every value change
61
+ const isInitialMount = React.useRef(true);
62
+ React.useEffect(() => {
63
+ if (isInitialMount.current) {
64
+ isInitialMount.current = false;
65
+ return;
66
+ }
67
+ // Don't re-detect type when value changes after initial mount
68
+ }, [value]);
69
+
70
+ const handleTypeChange = (newType: EditorType) => {
71
+ setSelectedType(newType);
72
+
73
+ // Clear value when switching to "none"
74
+ if (newType === "none") {
75
+ onChange("");
76
+ return;
77
+ }
78
+
79
+ // When switching to formdata
80
+ if (newType === "formdata") {
81
+ if (!value || value.trim() === "") {
82
+ // Empty value, start fresh
83
+ onChange("");
84
+ return;
85
+ }
86
+
87
+ // Try to convert from JSON
88
+ try {
89
+ const parsed = JSON.parse(value);
90
+ if (
91
+ typeof parsed === "object" &&
92
+ parsed !== null &&
93
+ !Array.isArray(parsed)
94
+ ) {
95
+ const pairs = Object.entries(parsed).map(([key, val]) => ({
96
+ key,
97
+ value: String(val),
98
+ }));
99
+ onChange(serializeFormData(pairs));
100
+ return;
101
+ }
102
+ } catch {
103
+ // Not JSON
104
+ }
105
+
106
+ // Check if already formdata-like (has = sign)
107
+ if (value.includes("=") && !value.includes("\n")) {
108
+ // Looks like formdata, keep it
109
+ return;
110
+ }
111
+
112
+ // Can't convert - clear value to start fresh
113
+ onChange("");
114
+ return;
115
+ }
116
+
117
+ // When switching to JSON from formdata, try to convert
118
+ if (newType === "json" && value && value.includes("=")) {
119
+ const pairs = parseFormData(value);
120
+ if (pairs.length > 0 && pairs.every((p) => p.key.trim() !== "")) {
121
+ const obj: Record<string, string> = {};
122
+ for (const pair of pairs) {
123
+ obj[pair.key] = pair.value;
124
+ }
125
+ try {
126
+ // eslint-disable-next-line unicorn/no-null -- JSON.stringify requires null for formatting
127
+ onChange(JSON.stringify(obj, null, 2));
128
+ return;
129
+ } catch {
130
+ // Keep current value
131
+ }
132
+ }
133
+ }
134
+ };
135
+
136
+ // Only show dropdown if there's more than one type
137
+ const showDropdown = editorTypes.length > 1;
138
+
139
+ return (
140
+ <div className="space-y-2">
141
+ <div className="flex items-start justify-between gap-4">
142
+ <div className="flex-1">
143
+ <Label htmlFor={id}>
144
+ {label} {isRequired && "*"}
145
+ </Label>
146
+ {description && (
147
+ <p className="text-sm text-muted-foreground mt-0.5">
148
+ {description}
149
+ </p>
150
+ )}
151
+ </div>
152
+ {showDropdown && (
153
+ <Select value={selectedType} onValueChange={handleTypeChange}>
154
+ <SelectTrigger className="w-[130px] h-8 text-xs">
155
+ <SelectValue />
156
+ </SelectTrigger>
157
+ <SelectContent>
158
+ {editorTypes.map((type) => (
159
+ <SelectItem key={type} value={type}>
160
+ {EDITOR_TYPE_LABELS[type]}
161
+ </SelectItem>
162
+ ))}
163
+ </SelectContent>
164
+ </Select>
165
+ )}
166
+ </div>
167
+
168
+ {/* Render appropriate editor based on selected type */}
169
+ {selectedType === "none" && (
170
+ <p className="text-sm text-muted-foreground italic py-2">
171
+ No content (disabled)
172
+ </p>
173
+ )}
174
+
175
+ {selectedType === "raw" && (
176
+ <RawEditor
177
+ id={id}
178
+ value={value ?? ""}
179
+ onChange={onChange}
180
+ templateProperties={templateProperties}
181
+ />
182
+ )}
183
+
184
+ {selectedType === "json" && (
185
+ <CodeEditor
186
+ id={id}
187
+ value={value ?? ""}
188
+ onChange={onChange}
189
+ language="json"
190
+ minHeight="150px"
191
+ templateProperties={templateProperties}
192
+ />
193
+ )}
194
+
195
+ {selectedType === "yaml" && (
196
+ <CodeEditor
197
+ id={id}
198
+ value={value ?? ""}
199
+ onChange={onChange}
200
+ language="yaml"
201
+ minHeight="150px"
202
+ templateProperties={templateProperties}
203
+ />
204
+ )}
205
+
206
+ {selectedType === "xml" && (
207
+ <CodeEditor
208
+ id={id}
209
+ value={value ?? ""}
210
+ onChange={onChange}
211
+ language="xml"
212
+ minHeight="150px"
213
+ templateProperties={templateProperties}
214
+ />
215
+ )}
216
+
217
+ {selectedType === "markdown" && (
218
+ <CodeEditor
219
+ id={id}
220
+ value={value ?? ""}
221
+ onChange={onChange}
222
+ language="markdown"
223
+ minHeight="150px"
224
+ templateProperties={templateProperties}
225
+ />
226
+ )}
227
+
228
+ {selectedType === "formdata" && (
229
+ <KeyValueEditor
230
+ id={id}
231
+ value={parseFormData(value ?? "")}
232
+ onChange={(pairs) => onChange(serializeFormData(pairs))}
233
+ templateProperties={templateProperties}
234
+ />
235
+ )}
236
+ </div>
237
+ );
238
+ };
239
+
240
+ /**
241
+ * Detect if cursor is inside an unclosed {{ template context.
242
+ */
243
+ function detectRawTemplateContext(text: string, cursorPos: number) {
244
+ const textBefore = text.slice(0, cursorPos);
245
+ const lastOpenBrace = textBefore.lastIndexOf("{{");
246
+ const lastCloseBrace = textBefore.lastIndexOf("}}");
247
+
248
+ if (lastOpenBrace !== -1 && lastOpenBrace > lastCloseBrace) {
249
+ const query = textBefore.slice(lastOpenBrace + 2);
250
+ if (!query.includes("\n")) {
251
+ return { isInTemplate: true, query, startPos: lastOpenBrace };
252
+ }
253
+ }
254
+ return { isInTemplate: false, query: "", startPos: -1 };
255
+ }
256
+
257
+ /**
258
+ * Raw text editor with optional template autocomplete.
259
+ * Uses a simple textarea with popup autocomplete (similar to original TemplateEditor).
260
+ */
261
+ const RawEditor: React.FC<{
262
+ id: string;
263
+ value: string;
264
+ onChange: (value: string) => void;
265
+ templateProperties?: TemplateProperty[];
266
+ }> = ({ id, value, onChange, templateProperties }) => {
267
+ const textareaRef = React.useRef<HTMLTextAreaElement>(null);
268
+ const containerRef = React.useRef<HTMLDivElement>(null);
269
+ const [showPopup, setShowPopup] = React.useState(false);
270
+ const [popupPosition, setPopupPosition] = React.useState({ top: 0, left: 0 });
271
+ const [selectedIndex, setSelectedIndex] = React.useState(0);
272
+ const [templateContext, setTemplateContext] = React.useState<{
273
+ query: string;
274
+ startPos: number;
275
+ }>({ query: "", startPos: -1 });
276
+
277
+ // Filter properties based on query
278
+ const filteredProperties = React.useMemo(() => {
279
+ if (!templateProperties) return [];
280
+ if (!templateContext.query.trim()) return templateProperties;
281
+ const lowerQuery = templateContext.query.toLowerCase();
282
+ return templateProperties.filter((prop) =>
283
+ prop.path.toLowerCase().includes(lowerQuery),
284
+ );
285
+ }, [templateProperties, templateContext.query]);
286
+
287
+ const calculatePopupPosition = React.useCallback(() => {
288
+ const textarea = textareaRef.current;
289
+ const container = containerRef.current;
290
+ if (!textarea || !container) return;
291
+
292
+ const lineHeight =
293
+ Number.parseInt(getComputedStyle(textarea).lineHeight) || 20;
294
+ const paddingTop =
295
+ Number.parseInt(getComputedStyle(textarea).paddingTop) || 8;
296
+ const cursorPos = textarea.selectionStart ?? 0;
297
+ const textBeforeCursor = value.slice(0, cursorPos);
298
+ const lines = textBeforeCursor.split("\n");
299
+ const currentLineIndex = lines.length - 1;
300
+
301
+ const top = paddingTop + (currentLineIndex + 1) * lineHeight;
302
+ const currentLine = lines[currentLineIndex] ?? "";
303
+ const charWidth = 8;
304
+ const paddingLeft =
305
+ Number.parseInt(getComputedStyle(textarea).paddingLeft) || 12;
306
+ const left = Math.min(paddingLeft + currentLine.length * charWidth, 200);
307
+
308
+ setPopupPosition({
309
+ top: Math.max(top, lineHeight),
310
+ left: Math.max(left, 0),
311
+ });
312
+ }, [value]);
313
+
314
+ const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
315
+ const newValue = e.target.value;
316
+ onChange(newValue);
317
+
318
+ if (!templateProperties || templateProperties.length === 0) return;
319
+
320
+ setTimeout(() => {
321
+ const textarea = textareaRef.current;
322
+ if (!textarea) return;
323
+
324
+ const cursorPos = textarea.selectionStart ?? 0;
325
+ const context = detectRawTemplateContext(newValue, cursorPos);
326
+
327
+ if (context.isInTemplate) {
328
+ setTemplateContext({
329
+ query: context.query,
330
+ startPos: context.startPos,
331
+ });
332
+ setShowPopup(true);
333
+ setSelectedIndex(0);
334
+ calculatePopupPosition();
335
+ } else {
336
+ setShowPopup(false);
337
+ }
338
+ }, 0);
339
+ };
340
+
341
+ const insertProperty = React.useCallback(
342
+ (prop: TemplateProperty) => {
343
+ const textarea = textareaRef.current;
344
+ if (!textarea || templateContext.startPos === -1) return;
345
+
346
+ const template = `{{${prop.path}}}`;
347
+ const cursorPos = textarea.selectionStart ?? 0;
348
+ const newValue =
349
+ value.slice(0, templateContext.startPos) +
350
+ template +
351
+ value.slice(cursorPos);
352
+
353
+ onChange(newValue);
354
+ setShowPopup(false);
355
+
356
+ const newPosition = templateContext.startPos + template.length;
357
+ setTimeout(() => {
358
+ textarea.focus();
359
+ textarea.setSelectionRange(newPosition, newPosition);
360
+ }, 0);
361
+ },
362
+ [value, onChange, templateContext.startPos],
363
+ );
364
+
365
+ const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
366
+ if (!showPopup || filteredProperties.length === 0) return;
367
+
368
+ switch (e.key) {
369
+ case "ArrowDown": {
370
+ e.preventDefault();
371
+ setSelectedIndex((prev) =>
372
+ prev < filteredProperties.length - 1 ? prev + 1 : 0,
373
+ );
374
+ break;
375
+ }
376
+ case "ArrowUp": {
377
+ e.preventDefault();
378
+ setSelectedIndex((prev) =>
379
+ prev > 0 ? prev - 1 : filteredProperties.length - 1,
380
+ );
381
+ break;
382
+ }
383
+ case "Enter":
384
+ case "Tab": {
385
+ e.preventDefault();
386
+ if (filteredProperties[selectedIndex]) {
387
+ insertProperty(filteredProperties[selectedIndex]);
388
+ }
389
+ break;
390
+ }
391
+ case "Escape": {
392
+ e.preventDefault();
393
+ setShowPopup(false);
394
+ break;
395
+ }
396
+ }
397
+ };
398
+
399
+ // Close popup on outside click
400
+ React.useEffect(() => {
401
+ const handleClickOutside = (event: MouseEvent) => {
402
+ if (
403
+ containerRef.current &&
404
+ !containerRef.current.contains(event.target as Node)
405
+ ) {
406
+ setShowPopup(false);
407
+ }
408
+ };
409
+
410
+ if (showPopup) {
411
+ document.addEventListener("mousedown", handleClickOutside);
412
+ return () =>
413
+ document.removeEventListener("mousedown", handleClickOutside);
414
+ }
415
+ }, [showPopup]);
416
+
417
+ return (
418
+ <div ref={containerRef} className="relative">
419
+ <Textarea
420
+ ref={textareaRef}
421
+ id={id}
422
+ value={value}
423
+ onChange={handleChange}
424
+ onKeyDown={handleKeyDown}
425
+ rows={5}
426
+ className="font-mono text-sm"
427
+ />
428
+ {showPopup && filteredProperties.length > 0 && (
429
+ <div
430
+ className="absolute z-50 w-72 max-h-48 overflow-y-auto rounded-md border border-border bg-popover shadow-lg"
431
+ style={{ top: popupPosition.top, left: popupPosition.left }}
432
+ >
433
+ <div className="p-1">
434
+ {filteredProperties.map((prop, index) => (
435
+ <button
436
+ key={prop.path}
437
+ type="button"
438
+ onClick={() => insertProperty(prop)}
439
+ className={`w-full flex items-center justify-between gap-2 px-2 py-1.5 text-xs rounded-sm text-left hover:bg-accent hover:text-accent-foreground ${
440
+ index === selectedIndex
441
+ ? "bg-accent text-accent-foreground"
442
+ : ""
443
+ }`}
444
+ >
445
+ <code className="font-mono truncate">{prop.path}</code>
446
+ <span className="text-muted-foreground shrink-0">
447
+ {prop.type}
448
+ </span>
449
+ </button>
450
+ ))}
451
+ </div>
452
+ </div>
453
+ )}
454
+ {templateProperties && templateProperties.length > 0 && !showPopup && (
455
+ <p className="text-xs text-muted-foreground mt-1.5">
456
+ Type{" "}
457
+ <code className="px-1 py-0.5 bg-muted rounded text-[10px]">
458
+ {"{{"}
459
+ </code>{" "}
460
+ for template suggestions
461
+ </p>
462
+ )}
463
+ </div>
464
+ );
465
+ };
@@ -1,6 +1,10 @@
1
1
  // Main component
2
2
  export { DynamicForm } from "./DynamicForm";
3
3
 
4
+ // Sub-components for advanced usage
5
+ export { MultiTypeEditorField } from "./MultiTypeEditorField";
6
+ export { KeyValueEditor, type KeyValuePair } from "./KeyValueEditor";
7
+
4
8
  // Types for external consumers
5
9
  export type {
6
10
  JsonSchema,
@@ -8,4 +12,13 @@ export type {
8
12
  DynamicFormProps,
9
13
  OptionsResolver,
10
14
  ResolverOption,
15
+ EditorType,
11
16
  } from "./types";
17
+
18
+ // Utility functions
19
+ export {
20
+ serializeFormData,
21
+ parseFormData,
22
+ detectEditorType,
23
+ EDITOR_TYPE_LABELS,
24
+ } from "./utils";
@@ -1,4 +1,8 @@
1
- import type { TemplateProperty } from "../TemplateEditor";
1
+ import type { TemplateProperty } from "../CodeEditor";
2
+ import type { EditorType } from "@checkstack/common";
3
+
4
+ // Re-export types used by multi-type editor
5
+ export type { EditorType } from "./utils";
2
6
  import type {
3
7
  JsonSchemaPropertyCore,
4
8
  JsonSchemaBase,
@@ -8,8 +12,7 @@ import type {
8
12
  * JSON Schema property with DynamicForm-specific x-* extensions for config rendering.
9
13
  * Uses the generic core type for proper recursive typing.
10
14
  */
11
- export interface JsonSchemaProperty
12
- extends JsonSchemaPropertyCore<JsonSchemaProperty> {
15
+ export interface JsonSchemaProperty extends JsonSchemaPropertyCore<JsonSchemaProperty> {
13
16
  // Config-specific x-* extensions
14
17
  "x-secret"?: boolean; // Field contains sensitive data
15
18
  "x-color"?: boolean; // Field is a color picker
@@ -17,6 +20,7 @@ export interface JsonSchemaProperty
17
20
  "x-depends-on"?: string[]; // Field names this field depends on (triggers refetch)
18
21
  "x-hidden"?: boolean; // Field should be hidden in form (auto-populated)
19
22
  "x-searchable"?: boolean; // Shows search input for filtering dropdown options
23
+ "x-editor-types"?: EditorType[]; // Available editor types for multi-type input
20
24
  }
21
25
 
22
26
  /** Option returned by an options resolver */
@@ -27,7 +31,7 @@ export interface ResolverOption {
27
31
 
28
32
  /** Function that resolves dynamic options, receives form values as context */
29
33
  export type OptionsResolver = (
30
- formValues: Record<string, unknown>
34
+ formValues: Record<string, unknown>,
31
35
  ) => Promise<ResolverOption[]>;
32
36
 
33
37
  /**
@@ -50,8 +54,8 @@ export interface DynamicFormProps {
50
54
  */
51
55
  optionsResolvers?: Record<string, OptionsResolver>;
52
56
  /**
53
- * Optional list of available template properties for template fields.
54
- * Passed to TemplateEditor for autocompletion hints.
57
+ * Optional list of available template properties for multi-type editor fields.
58
+ * When provided, fields with x-editor-types get {{ autocomplete suggestions.
55
59
  */
56
60
  templateProperties?: TemplateProperty[];
57
61
  }
@@ -66,7 +70,8 @@ export interface FormFieldProps {
66
70
  formValues: Record<string, unknown>;
67
71
  optionsResolvers?: Record<string, OptionsResolver>;
68
72
  templateProperties?: TemplateProperty[];
69
- onChange: (val: unknown) => void;
73
+ /** Callback when value changes. Omit val to clear the field. */
74
+ onChange: (val?: unknown) => void;
70
75
  }
71
76
 
72
77
  /** Props for the DynamicOptionsField component */
@@ -81,7 +86,8 @@ export interface DynamicOptionsFieldProps {
81
86
  searchable?: boolean;
82
87
  formValues: Record<string, unknown>;
83
88
  optionsResolvers: Record<string, OptionsResolver>;
84
- onChange: (val: unknown) => void;
89
+ /** Callback when value changes. Omit val to clear the field. */
90
+ onChange: (val?: unknown) => void;
85
91
  }
86
92
 
87
93
  /** Props for the JsonField component */