@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.
- package/CHANGELOG.md +30 -0
- package/package.json +10 -3
- package/src/components/CodeEditor/CodeEditor.tsx +420 -0
- package/src/components/CodeEditor/index.ts +10 -0
- package/src/components/CodeEditor/languageSupport/enterBehavior.test.ts +173 -0
- package/src/components/CodeEditor/languageSupport/enterBehavior.ts +35 -0
- package/src/components/CodeEditor/languageSupport/index.ts +22 -0
- package/src/components/CodeEditor/languageSupport/json.test.ts +271 -0
- package/src/components/CodeEditor/languageSupport/json.ts +240 -0
- package/src/components/CodeEditor/languageSupport/markdown.test.ts +245 -0
- package/src/components/CodeEditor/languageSupport/markdown.ts +183 -0
- package/src/components/CodeEditor/languageSupport/types.ts +48 -0
- package/src/components/CodeEditor/languageSupport/xml.test.ts +236 -0
- package/src/components/CodeEditor/languageSupport/xml.ts +194 -0
- package/src/components/CodeEditor/languageSupport/yaml.test.ts +200 -0
- package/src/components/CodeEditor/languageSupport/yaml.ts +205 -0
- package/src/components/DynamicForm/DynamicForm.tsx +2 -24
- package/src/components/DynamicForm/DynamicOptionsField.tsx +48 -21
- package/src/components/DynamicForm/FormField.tsx +38 -70
- package/src/components/DynamicForm/JsonField.tsx +19 -25
- package/src/components/DynamicForm/KeyValueEditor.tsx +314 -0
- package/src/components/DynamicForm/MultiTypeEditorField.tsx +465 -0
- package/src/components/DynamicForm/index.ts +13 -0
- package/src/components/DynamicForm/types.ts +14 -8
- package/src/components/DynamicForm/utils.test.ts +390 -0
- package/src/components/DynamicForm/utils.ts +142 -3
- package/src/index.ts +1 -1
- package/src/components/TemplateEditor.test.ts +0 -156
- 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 "../
|
|
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
|
|
54
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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 */
|