@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,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
+ };