@checkstack/ui 0.2.3 → 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 +48 -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/components/StrategyConfigCard.tsx +8 -4
- package/src/index.ts +1 -1
- package/src/components/TemplateEditor.test.ts +0 -156
- package/src/components/TemplateEditor.tsx +0 -435
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { Plus, Trash2 } from "lucide-react";
|
|
3
|
+
import { Button } from "../Button";
|
|
4
|
+
import { Input } from "../Input";
|
|
5
|
+
import type { TemplateProperty } from "../CodeEditor";
|
|
6
|
+
|
|
7
|
+
export interface KeyValuePair {
|
|
8
|
+
key: string;
|
|
9
|
+
value: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface KeyValueEditorProps {
|
|
13
|
+
/** Unique identifier for the editor */
|
|
14
|
+
id: string;
|
|
15
|
+
/** Current key-value pairs */
|
|
16
|
+
value: KeyValuePair[];
|
|
17
|
+
/** Callback when pairs change */
|
|
18
|
+
onChange: (pairs: KeyValuePair[]) => void;
|
|
19
|
+
/** Placeholder for key input */
|
|
20
|
+
keyPlaceholder?: string;
|
|
21
|
+
/** Placeholder for value input */
|
|
22
|
+
valuePlaceholder?: string;
|
|
23
|
+
/**
|
|
24
|
+
* Optional template properties for autocomplete in value fields.
|
|
25
|
+
* Note: Template autocomplete in value fields uses simple detection
|
|
26
|
+
* rather than CodeMirror, since these are single-line inputs.
|
|
27
|
+
*/
|
|
28
|
+
templateProperties?: TemplateProperty[];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Detect if cursor is inside an unclosed {{ template context.
|
|
33
|
+
*/
|
|
34
|
+
function detectTemplateContext(text: string, cursorPos: number) {
|
|
35
|
+
const textBefore = text.slice(0, cursorPos);
|
|
36
|
+
const lastOpenBrace = textBefore.lastIndexOf("{{");
|
|
37
|
+
const lastCloseBrace = textBefore.lastIndexOf("}}");
|
|
38
|
+
|
|
39
|
+
if (lastOpenBrace !== -1 && lastOpenBrace > lastCloseBrace) {
|
|
40
|
+
return {
|
|
41
|
+
isInTemplate: true,
|
|
42
|
+
query: textBefore.slice(lastOpenBrace + 2),
|
|
43
|
+
startPos: lastOpenBrace,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
return { isInTemplate: false, query: "", startPos: -1 };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* A key/value pair editor for form data and similar use cases.
|
|
51
|
+
* Supports adding/removing pairs and optional template autocomplete in values.
|
|
52
|
+
*
|
|
53
|
+
* Uses internal state to manage pairs with empty keys (which are filtered
|
|
54
|
+
* when serializing), allowing users to add new items before filling them in.
|
|
55
|
+
*/
|
|
56
|
+
export const KeyValueEditor: React.FC<KeyValueEditorProps> = ({
|
|
57
|
+
id,
|
|
58
|
+
value: externalValue,
|
|
59
|
+
onChange,
|
|
60
|
+
keyPlaceholder = "Key",
|
|
61
|
+
valuePlaceholder = "Value",
|
|
62
|
+
templateProperties,
|
|
63
|
+
}) => {
|
|
64
|
+
// Use internal state that allows empty keys (for new items)
|
|
65
|
+
const [internalPairs, setInternalPairs] = React.useState<KeyValuePair[]>(
|
|
66
|
+
() => (externalValue.length > 0 ? externalValue : []),
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
// Track if the last change was from internal editing
|
|
70
|
+
const isInternalChangeRef = React.useRef(false);
|
|
71
|
+
|
|
72
|
+
// Sync internal state only when external value meaningfully changes
|
|
73
|
+
// (e.g., from format conversion), not from our own serialization
|
|
74
|
+
React.useEffect(() => {
|
|
75
|
+
// Skip sync if we just made an internal change
|
|
76
|
+
if (isInternalChangeRef.current) {
|
|
77
|
+
isInternalChangeRef.current = false;
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Only sync if external value has content we don't have
|
|
82
|
+
// This handles cases like switching from JSON -> formdata
|
|
83
|
+
if (externalValue.length > 0) {
|
|
84
|
+
setInternalPairs(externalValue);
|
|
85
|
+
}
|
|
86
|
+
}, [externalValue]);
|
|
87
|
+
|
|
88
|
+
const notifyChange = (pairs: KeyValuePair[]) => {
|
|
89
|
+
isInternalChangeRef.current = true;
|
|
90
|
+
setInternalPairs(pairs);
|
|
91
|
+
// Notify parent - they will filter empty keys when serializing
|
|
92
|
+
onChange(pairs);
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
const handleAdd = () => {
|
|
96
|
+
notifyChange([...internalPairs, { key: "", value: "" }]);
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
const handleRemove = (index: number) => {
|
|
100
|
+
const next = [...internalPairs];
|
|
101
|
+
next.splice(index, 1);
|
|
102
|
+
notifyChange(next);
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
const handleKeyChange = (index: number, newKey: string) => {
|
|
106
|
+
const next = [...internalPairs];
|
|
107
|
+
next[index] = { ...next[index], key: newKey };
|
|
108
|
+
notifyChange(next);
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
const handleValueChange = (index: number, newValue: string) => {
|
|
112
|
+
const next = [...internalPairs];
|
|
113
|
+
next[index] = { ...next[index], value: newValue };
|
|
114
|
+
notifyChange(next);
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
return (
|
|
118
|
+
<div className="space-y-2">
|
|
119
|
+
{internalPairs.length === 0 && (
|
|
120
|
+
<p className="text-xs text-muted-foreground italic">
|
|
121
|
+
No items added yet.
|
|
122
|
+
</p>
|
|
123
|
+
)}
|
|
124
|
+
{internalPairs.map((pair, index) => (
|
|
125
|
+
<div key={index} className="flex items-center gap-2">
|
|
126
|
+
<Input
|
|
127
|
+
id={`${id}-key-${index}`}
|
|
128
|
+
value={pair.key}
|
|
129
|
+
onChange={(e) => handleKeyChange(index, e.target.value)}
|
|
130
|
+
placeholder={keyPlaceholder}
|
|
131
|
+
className="flex-1 font-mono text-sm"
|
|
132
|
+
/>
|
|
133
|
+
<span className="text-muted-foreground">=</span>
|
|
134
|
+
<TemplateInput
|
|
135
|
+
id={`${id}-value-${index}`}
|
|
136
|
+
value={pair.value}
|
|
137
|
+
onChange={(newValue) => handleValueChange(index, newValue)}
|
|
138
|
+
placeholder={valuePlaceholder}
|
|
139
|
+
templateProperties={templateProperties}
|
|
140
|
+
/>
|
|
141
|
+
<Button
|
|
142
|
+
type="button"
|
|
143
|
+
variant="ghost"
|
|
144
|
+
size="icon"
|
|
145
|
+
onClick={() => handleRemove(index)}
|
|
146
|
+
className="h-8 w-8 text-destructive hover:text-destructive/90 hover:bg-destructive/10 shrink-0"
|
|
147
|
+
>
|
|
148
|
+
<Trash2 className="h-4 w-4" />
|
|
149
|
+
</Button>
|
|
150
|
+
</div>
|
|
151
|
+
))}
|
|
152
|
+
<Button
|
|
153
|
+
type="button"
|
|
154
|
+
variant="outline"
|
|
155
|
+
size="sm"
|
|
156
|
+
onClick={handleAdd}
|
|
157
|
+
className="h-8 gap-1"
|
|
158
|
+
>
|
|
159
|
+
<Plus className="h-4 w-4" />
|
|
160
|
+
Add Item
|
|
161
|
+
</Button>
|
|
162
|
+
</div>
|
|
163
|
+
);
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* An input with simple template autocomplete support.
|
|
168
|
+
* Shows a dropdown when user types "{{".
|
|
169
|
+
*/
|
|
170
|
+
const TemplateInput: React.FC<{
|
|
171
|
+
id: string;
|
|
172
|
+
value: string;
|
|
173
|
+
onChange: (value: string) => void;
|
|
174
|
+
placeholder?: string;
|
|
175
|
+
templateProperties?: TemplateProperty[];
|
|
176
|
+
}> = ({ id, value, onChange, placeholder, templateProperties }) => {
|
|
177
|
+
const inputRef = React.useRef<HTMLInputElement>(null);
|
|
178
|
+
const [showPopup, setShowPopup] = React.useState(false);
|
|
179
|
+
const [selectedIndex, setSelectedIndex] = React.useState(0);
|
|
180
|
+
const [templateContext, setTemplateContext] = React.useState<{
|
|
181
|
+
query: string;
|
|
182
|
+
startPos: number;
|
|
183
|
+
}>({ query: "", startPos: -1 });
|
|
184
|
+
|
|
185
|
+
// Filter properties based on query
|
|
186
|
+
const filteredProperties = React.useMemo(() => {
|
|
187
|
+
if (!templateProperties) return [];
|
|
188
|
+
if (!templateContext.query.trim()) return templateProperties;
|
|
189
|
+
const lowerQuery = templateContext.query.toLowerCase();
|
|
190
|
+
return templateProperties.filter((prop) =>
|
|
191
|
+
prop.path.toLowerCase().includes(lowerQuery),
|
|
192
|
+
);
|
|
193
|
+
}, [templateProperties, templateContext.query]);
|
|
194
|
+
|
|
195
|
+
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
196
|
+
const newValue = e.target.value;
|
|
197
|
+
onChange(newValue);
|
|
198
|
+
|
|
199
|
+
if (!templateProperties || templateProperties.length === 0) return;
|
|
200
|
+
|
|
201
|
+
const cursorPos = e.target.selectionStart ?? newValue.length;
|
|
202
|
+
const context = detectTemplateContext(newValue, cursorPos);
|
|
203
|
+
|
|
204
|
+
if (context.isInTemplate) {
|
|
205
|
+
setTemplateContext({ query: context.query, startPos: context.startPos });
|
|
206
|
+
setShowPopup(true);
|
|
207
|
+
setSelectedIndex(0);
|
|
208
|
+
} else {
|
|
209
|
+
setShowPopup(false);
|
|
210
|
+
}
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
const insertProperty = (prop: TemplateProperty) => {
|
|
214
|
+
if (templateContext.startPos === -1) return;
|
|
215
|
+
|
|
216
|
+
const cursorPos = inputRef.current?.selectionStart ?? value.length;
|
|
217
|
+
const template = `{{${prop.path}}}`;
|
|
218
|
+
const newValue =
|
|
219
|
+
value.slice(0, templateContext.startPos) +
|
|
220
|
+
template +
|
|
221
|
+
value.slice(cursorPos);
|
|
222
|
+
|
|
223
|
+
onChange(newValue);
|
|
224
|
+
setShowPopup(false);
|
|
225
|
+
|
|
226
|
+
// Restore focus
|
|
227
|
+
setTimeout(() => {
|
|
228
|
+
inputRef.current?.focus();
|
|
229
|
+
const newPos = templateContext.startPos + template.length;
|
|
230
|
+
inputRef.current?.setSelectionRange(newPos, newPos);
|
|
231
|
+
}, 0);
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
|
235
|
+
if (!showPopup || filteredProperties.length === 0) return;
|
|
236
|
+
|
|
237
|
+
switch (e.key) {
|
|
238
|
+
case "ArrowDown": {
|
|
239
|
+
e.preventDefault();
|
|
240
|
+
setSelectedIndex((prev) =>
|
|
241
|
+
prev < filteredProperties.length - 1 ? prev + 1 : 0,
|
|
242
|
+
);
|
|
243
|
+
break;
|
|
244
|
+
}
|
|
245
|
+
case "ArrowUp": {
|
|
246
|
+
e.preventDefault();
|
|
247
|
+
setSelectedIndex((prev) =>
|
|
248
|
+
prev > 0 ? prev - 1 : filteredProperties.length - 1,
|
|
249
|
+
);
|
|
250
|
+
break;
|
|
251
|
+
}
|
|
252
|
+
case "Enter":
|
|
253
|
+
case "Tab": {
|
|
254
|
+
e.preventDefault();
|
|
255
|
+
if (filteredProperties[selectedIndex]) {
|
|
256
|
+
insertProperty(filteredProperties[selectedIndex]);
|
|
257
|
+
}
|
|
258
|
+
break;
|
|
259
|
+
}
|
|
260
|
+
case "Escape": {
|
|
261
|
+
e.preventDefault();
|
|
262
|
+
setShowPopup(false);
|
|
263
|
+
break;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
};
|
|
267
|
+
|
|
268
|
+
// Close popup on blur
|
|
269
|
+
const handleBlur = () => {
|
|
270
|
+
// Delay to allow click on popup item
|
|
271
|
+
setTimeout(() => setShowPopup(false), 150);
|
|
272
|
+
};
|
|
273
|
+
|
|
274
|
+
return (
|
|
275
|
+
<div className="relative flex-1">
|
|
276
|
+
<Input
|
|
277
|
+
ref={inputRef}
|
|
278
|
+
id={id}
|
|
279
|
+
value={value}
|
|
280
|
+
onChange={handleChange}
|
|
281
|
+
onKeyDown={handleKeyDown}
|
|
282
|
+
onBlur={handleBlur}
|
|
283
|
+
placeholder={placeholder}
|
|
284
|
+
className="font-mono text-sm"
|
|
285
|
+
/>
|
|
286
|
+
{showPopup && filteredProperties.length > 0 && (
|
|
287
|
+
<div className="absolute z-50 top-full left-0 mt-1 w-64 max-h-48 overflow-y-auto rounded-md border border-border bg-popover shadow-lg">
|
|
288
|
+
<div className="p-1">
|
|
289
|
+
{filteredProperties.map((prop, index) => (
|
|
290
|
+
<button
|
|
291
|
+
key={prop.path}
|
|
292
|
+
type="button"
|
|
293
|
+
onMouseDown={(e) => {
|
|
294
|
+
e.preventDefault();
|
|
295
|
+
insertProperty(prop);
|
|
296
|
+
}}
|
|
297
|
+
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 ${
|
|
298
|
+
index === selectedIndex
|
|
299
|
+
? "bg-accent text-accent-foreground"
|
|
300
|
+
: ""
|
|
301
|
+
}`}
|
|
302
|
+
>
|
|
303
|
+
<code className="font-mono truncate">{prop.path}</code>
|
|
304
|
+
<span className="text-muted-foreground shrink-0">
|
|
305
|
+
{prop.type}
|
|
306
|
+
</span>
|
|
307
|
+
</button>
|
|
308
|
+
))}
|
|
309
|
+
</div>
|
|
310
|
+
</div>
|
|
311
|
+
)}
|
|
312
|
+
</div>
|
|
313
|
+
);
|
|
314
|
+
};
|