@checkstack/ui 0.5.3 → 1.0.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 (27) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/package.json +3 -10
  3. package/src/components/CodeEditor/CodeEditor.tsx +14 -420
  4. package/src/components/CodeEditor/MonacoEditor.tsx +530 -0
  5. package/src/components/CodeEditor/generateTypeDefinitions.ts +169 -0
  6. package/src/components/CodeEditor/index.ts +4 -3
  7. package/src/components/CodeEditor/templateUtils.test.ts +87 -0
  8. package/src/components/CodeEditor/templateUtils.ts +81 -0
  9. package/src/components/DynamicForm/FormField.tsx +13 -7
  10. package/src/components/DynamicForm/MultiTypeEditorField.tsx +33 -0
  11. package/src/components/DynamicForm/utils.ts +3 -0
  12. package/src/components/CodeEditor/languageSupport/enterBehavior.test.ts +0 -173
  13. package/src/components/CodeEditor/languageSupport/enterBehavior.ts +0 -35
  14. package/src/components/CodeEditor/languageSupport/index.ts +0 -22
  15. package/src/components/CodeEditor/languageSupport/json-utils.ts +0 -117
  16. package/src/components/CodeEditor/languageSupport/json.test.ts +0 -274
  17. package/src/components/CodeEditor/languageSupport/json.ts +0 -139
  18. package/src/components/CodeEditor/languageSupport/markdown-utils.ts +0 -65
  19. package/src/components/CodeEditor/languageSupport/markdown.test.ts +0 -245
  20. package/src/components/CodeEditor/languageSupport/markdown.ts +0 -134
  21. package/src/components/CodeEditor/languageSupport/types.ts +0 -48
  22. package/src/components/CodeEditor/languageSupport/xml-utils.ts +0 -94
  23. package/src/components/CodeEditor/languageSupport/xml.test.ts +0 -239
  24. package/src/components/CodeEditor/languageSupport/xml.ts +0 -116
  25. package/src/components/CodeEditor/languageSupport/yaml-utils.ts +0 -101
  26. package/src/components/CodeEditor/languageSupport/yaml.test.ts +0 -203
  27. package/src/components/CodeEditor/languageSupport/yaml.ts +0 -120
@@ -0,0 +1,87 @@
1
+ import { describe, it, expect } from "bun:test";
2
+ import { detectOpenTemplate, detectAutoClosedBraces } from "./templateUtils";
3
+
4
+ describe("detectOpenTemplate", () => {
5
+ it("returns isInTemplate=false when no open template", () => {
6
+ const content = '{"foo": "bar"}';
7
+ const result = detectOpenTemplate({ content, cursorOffset: 10 });
8
+ expect(result.isInTemplate).toBe(false);
9
+ });
10
+
11
+ it("returns isInTemplate=false when template is closed", () => {
12
+ const content = '{"foo": "{{test}}"}';
13
+ const result = detectOpenTemplate({ content, cursorOffset: 18 });
14
+ expect(result.isInTemplate).toBe(false);
15
+ });
16
+
17
+ it("returns isInTemplate=true when inside open template", () => {
18
+ const content = '{"foo": "{{payl';
19
+ const result = detectOpenTemplate({
20
+ content,
21
+ cursorOffset: content.length,
22
+ });
23
+ expect(result.isInTemplate).toBe(true);
24
+ expect(result.query).toBe("payl");
25
+ });
26
+
27
+ it("returns correct startOffset", () => {
28
+ const content = '{"foo": "{{test';
29
+ const result = detectOpenTemplate({
30
+ content,
31
+ cursorOffset: content.length,
32
+ });
33
+ expect(result.startOffset).toBe(9);
34
+ });
35
+
36
+ it("returns correct line number and column", () => {
37
+ const content = `{
38
+ "foo": "{{test`;
39
+ const result = detectOpenTemplate({
40
+ content,
41
+ cursorOffset: content.length,
42
+ });
43
+ expect(result.lineNumber).toBe(2);
44
+ expect(result.startColumn).toBe(11);
45
+ });
46
+
47
+ it("handles cursor right after {{", () => {
48
+ const content = '{"foo": "{{';
49
+ const result = detectOpenTemplate({
50
+ content,
51
+ cursorOffset: content.length,
52
+ });
53
+ expect(result.isInTemplate).toBe(true);
54
+ expect(result.query).toBe("");
55
+ });
56
+
57
+ it("returns isInTemplate=false when cursor is after closed and before new open", () => {
58
+ const content = '{"a": "{{done}}", "b": "';
59
+ const result = detectOpenTemplate({
60
+ content,
61
+ cursorOffset: content.length,
62
+ });
63
+ expect(result.isInTemplate).toBe(false);
64
+ });
65
+ });
66
+
67
+ describe("detectAutoClosedBraces", () => {
68
+ it("returns 0 when no braces after cursor", () => {
69
+ const content = '{"foo": "{{test}}"}';
70
+ const result = detectAutoClosedBraces({ content, cursorOffset: 9 });
71
+ expect(result).toBe(0);
72
+ });
73
+
74
+ it("returns 2 when }} follows cursor", () => {
75
+ const content = '{"foo": "{{test}}"}';
76
+ // Cursor is after "test" but before }}
77
+ const result = detectAutoClosedBraces({ content, cursorOffset: 15 });
78
+ expect(result).toBe(2);
79
+ });
80
+
81
+ it("returns 1 when single } follows cursor", () => {
82
+ const content = '{"foo": "{{test}"}';
83
+ // Only one } after test
84
+ const result = detectAutoClosedBraces({ content, cursorOffset: 15 });
85
+ expect(result).toBe(1);
86
+ });
87
+ });
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Template utilities for Monaco Editor
3
+ * Handles detection and position tracking of {{...}} template syntax
4
+ */
5
+
6
+ /**
7
+ * Information about an unclosed template at the cursor
8
+ */
9
+ export interface OpenTemplateContext {
10
+ /** Whether we're inside an unclosed {{ */
11
+ isInTemplate: boolean;
12
+ /** The text typed after {{ */
13
+ query: string;
14
+ /** The offset where {{ starts */
15
+ startOffset: number;
16
+ /** The line number where {{ is (1-indexed) */
17
+ lineNumber: number;
18
+ /** The column where {{ starts (1-indexed) */
19
+ startColumn: number;
20
+ }
21
+
22
+ /**
23
+ * Detect if cursor is inside an unclosed {{ template context
24
+ */
25
+ export function detectOpenTemplate({
26
+ content,
27
+ cursorOffset,
28
+ }: {
29
+ content: string;
30
+ cursorOffset: number;
31
+ }): OpenTemplateContext {
32
+ const textBefore = content.slice(0, cursorOffset);
33
+ const lastOpen = textBefore.lastIndexOf("{{");
34
+ const lastClose = textBefore.lastIndexOf("}}");
35
+
36
+ if (lastOpen === -1 || lastOpen <= lastClose) {
37
+ return {
38
+ isInTemplate: false,
39
+ query: "",
40
+ startOffset: -1,
41
+ lineNumber: 0,
42
+ startColumn: 0,
43
+ };
44
+ }
45
+
46
+ const query = textBefore.slice(lastOpen + 2);
47
+
48
+ // Calculate line number and column
49
+ const beforeTemplate = content.slice(0, lastOpen);
50
+ const lines = beforeTemplate.split("\n");
51
+ const lineNumber = lines.length;
52
+ const startColumn = (lines.at(-1)?.length ?? 0) + 1;
53
+
54
+ return {
55
+ isInTemplate: true,
56
+ query,
57
+ startOffset: lastOpen,
58
+ lineNumber,
59
+ startColumn,
60
+ };
61
+ }
62
+
63
+ /**
64
+ * Check if there are auto-closed braces after the cursor
65
+ */
66
+ export function detectAutoClosedBraces({
67
+ content,
68
+ cursorOffset,
69
+ }: {
70
+ content: string;
71
+ cursorOffset: number;
72
+ }): number {
73
+ const textAfter = content.slice(cursorOffset);
74
+ if (textAfter.startsWith("}}")) {
75
+ return 2;
76
+ }
77
+ if (textAfter.startsWith("}")) {
78
+ return 1;
79
+ }
80
+ return 0;
81
+ }
@@ -349,7 +349,18 @@ export const FormField: React.FC<FormFieldProps> = ({
349
349
  if (!itemSchema) return <></>;
350
350
 
351
351
  // Helper to create initial value for new array items
352
- const createNewItem = (): Record<string, unknown> => {
352
+ const createNewItem = (): unknown => {
353
+ // Handle primitive types
354
+ if (itemSchema.type === "string") {
355
+ return itemSchema.default ?? "";
356
+ }
357
+ if (itemSchema.type === "number" || itemSchema.type === "integer") {
358
+ return itemSchema.default ?? 0;
359
+ }
360
+ if (itemSchema.type === "boolean") {
361
+ return itemSchema.default ?? false;
362
+ }
363
+
353
364
  // Check if itemSchema is a discriminated union
354
365
  const variants = itemSchema.oneOf || itemSchema.anyOf;
355
366
  if (variants && variants.length > 0) {
@@ -391,12 +402,7 @@ export const FormField: React.FC<FormFieldProps> = ({
391
402
  type="button"
392
403
  variant="outline"
393
404
  size="sm"
394
- onClick={() =>
395
- onChange([
396
- ...(items as Record<string, unknown>[]),
397
- createNewItem(),
398
- ])
399
- }
405
+ onClick={() => onChange([...items, createNewItem()])}
400
406
  className="h-8 gap-1 transition-all hover:bg-accent hover:text-accent-foreground"
401
407
  >
402
408
  <Plus className="h-4 w-4" />
@@ -233,6 +233,39 @@ export const MultiTypeEditorField: React.FC<MultiTypeEditorFieldProps> = ({
233
233
  templateProperties={templateProperties}
234
234
  />
235
235
  )}
236
+
237
+ {selectedType === "javascript" && (
238
+ <CodeEditor
239
+ id={id}
240
+ value={value ?? ""}
241
+ onChange={onChange}
242
+ language="javascript"
243
+ minHeight="150px"
244
+ templateProperties={templateProperties}
245
+ />
246
+ )}
247
+
248
+ {selectedType === "typescript" && (
249
+ <CodeEditor
250
+ id={id}
251
+ value={value ?? ""}
252
+ onChange={onChange}
253
+ language="typescript"
254
+ minHeight="150px"
255
+ templateProperties={templateProperties}
256
+ />
257
+ )}
258
+
259
+ {selectedType === "shell" && (
260
+ <CodeEditor
261
+ id={id}
262
+ value={value ?? ""}
263
+ onChange={onChange}
264
+ language="shell"
265
+ minHeight="150px"
266
+ templateProperties={templateProperties}
267
+ />
268
+ )}
236
269
  </div>
237
270
  );
238
271
  };
@@ -175,4 +175,7 @@ export const EDITOR_TYPE_LABELS: Record<EditorType, string> = {
175
175
  xml: "XML",
176
176
  markdown: "Markdown",
177
177
  formdata: "Form Data",
178
+ javascript: "JavaScript",
179
+ typescript: "TypeScript",
180
+ shell: "Shell",
178
181
  };
@@ -1,173 +0,0 @@
1
- import { describe, expect, it } from "bun:test";
2
-
3
- import { isBetweenBrackets } from "./enterBehavior";
4
-
5
- describe("isBetweenBrackets", () => {
6
- // ============================================================================
7
- // Curly braces
8
- // ============================================================================
9
- describe("curly braces", () => {
10
- it("returns true for empty object {|}", () => {
11
- expect(isBetweenBrackets("{", "}")).toBe(true);
12
- });
13
-
14
- it("returns false when content before closing brace", () => {
15
- // Last char is "1", not "{"
16
- expect(isBetweenBrackets('{"a": 1', "}")).toBe(false);
17
- });
18
-
19
- it("returns false when only opening brace before", () => {
20
- expect(isBetweenBrackets("{", "x")).toBe(false);
21
- });
22
-
23
- it("returns false when only closing brace after", () => {
24
- expect(isBetweenBrackets("x", "}")).toBe(false);
25
- });
26
-
27
- it("returns false for nested opening {|{", () => {
28
- expect(isBetweenBrackets("{", "{")).toBe(false);
29
- });
30
- });
31
-
32
- // ============================================================================
33
- // Square brackets
34
- // ============================================================================
35
- describe("square brackets", () => {
36
- it("returns true for empty array [|]", () => {
37
- expect(isBetweenBrackets("[", "]")).toBe(true);
38
- });
39
-
40
- it("returns false when content before closing bracket", () => {
41
- // Last char is "2", not "["
42
- expect(isBetweenBrackets("[1, 2", "]")).toBe(false);
43
- });
44
-
45
- it("returns false when only opening bracket before", () => {
46
- expect(isBetweenBrackets("[", "x")).toBe(false);
47
- });
48
-
49
- it("returns false when only closing bracket after", () => {
50
- expect(isBetweenBrackets("x", "]")).toBe(false);
51
- });
52
-
53
- it("returns false for nested opening [|[", () => {
54
- expect(isBetweenBrackets("[", "[")).toBe(false);
55
- });
56
- });
57
-
58
- // ============================================================================
59
- // XML/HTML tags
60
- // ============================================================================
61
- describe("XML/HTML tags", () => {
62
- it("returns true for empty tag <div>|</div>", () => {
63
- expect(isBetweenBrackets("<div>", "</div>")).toBe(true);
64
- });
65
-
66
- it('returns true for tag with attributes <div class="x">|</div>', () => {
67
- expect(isBetweenBrackets('<div class="x">', "</div>")).toBe(true);
68
- });
69
-
70
- it("returns true for nested tags <outer><inner>|</inner>", () => {
71
- expect(isBetweenBrackets("<outer><inner>", "</inner>")).toBe(true);
72
- });
73
-
74
- it("returns true for self-named closing tag <a>|</a>", () => {
75
- expect(isBetweenBrackets("<a>", "</a>")).toBe(true);
76
- });
77
-
78
- it("returns false for adjacent opening tags <a>|<b>", () => {
79
- expect(isBetweenBrackets("<a>", "<b>")).toBe(false);
80
- });
81
-
82
- it("returns false for text after tag <div>|text", () => {
83
- expect(isBetweenBrackets("<div>", "text")).toBe(false);
84
- });
85
-
86
- it("returns false for self-closing tag <br/>|", () => {
87
- expect(isBetweenBrackets("<br/>", "")).toBe(false);
88
- });
89
-
90
- it("returns false for comment after tag <div>|<!--", () => {
91
- expect(isBetweenBrackets("<div>", "<!--")).toBe(false);
92
- });
93
- });
94
-
95
- // ============================================================================
96
- // Edge cases
97
- // ============================================================================
98
- describe("edge cases", () => {
99
- it("returns false for empty strings", () => {
100
- expect(isBetweenBrackets("", "")).toBe(false);
101
- });
102
-
103
- it("returns false for whitespace only", () => {
104
- expect(isBetweenBrackets(" ", " ")).toBe(false);
105
- });
106
-
107
- it("returns false for newlines", () => {
108
- expect(isBetweenBrackets("{\n", "\n}")).toBe(false);
109
- });
110
-
111
- it("returns true for template braces {{|}}", () => {
112
- // { followed by } - pattern matches even though it's part of template
113
- expect(isBetweenBrackets("{{", "}}")).toBe(true);
114
- });
115
-
116
- it("handles single character inputs", () => {
117
- expect(isBetweenBrackets("a", "b")).toBe(false);
118
- });
119
- });
120
-
121
- // ============================================================================
122
- // Real-world scenarios
123
- // ============================================================================
124
- describe("real-world scenarios", () => {
125
- it("JSON: empty object literal", () => {
126
- expect(isBetweenBrackets('{"name": ', "}")).toBe(false); // Not at the boundary
127
- });
128
-
129
- it("JSON: cursor right after opening brace", () => {
130
- expect(isBetweenBrackets("{", ' "name": "value"}')).toBe(false); // Has content
131
- });
132
-
133
- it("XML: HTML doctype before tag", () => {
134
- expect(isBetweenBrackets("<!DOCTYPE html><html>", "</html>")).toBe(true);
135
- });
136
-
137
- it("XML: after text content in tag", () => {
138
- expect(isBetweenBrackets("<p>Hello", "</p>")).toBe(false); // "o" != ">"
139
- });
140
-
141
- it("YAML: no brackets involved", () => {
142
- expect(isBetweenBrackets("key:", "")).toBe(false);
143
- });
144
- });
145
-
146
- // ============================================================================
147
- // Regression tests
148
- // ============================================================================
149
- describe("regression: autocomplete interaction", () => {
150
- /**
151
- * REGRESSION TEST (documented, requires DOM testing)
152
- *
153
- * Issue: When autocomplete popup is showing, pressing Enter should select
154
- * the completion item, NOT insert a newline.
155
- *
156
- * Fix: In CodeEditor.tsx, the custom Enter keymap checks `completionStatus(state)`
157
- * and returns `false` when autocomplete is "active", allowing the autocomplete
158
- * extension to handle the Enter key.
159
- *
160
- * This cannot be unit tested without a full CodeMirror DOM integration.
161
- * Manual verification steps:
162
- * 1. Type "{{" in the editor with template properties configured
163
- * 2. Autocomplete popup should appear
164
- * 3. Press Enter to select a template
165
- * 4. Template should be inserted (NOT a newline)
166
- */
167
- it("documents the autocomplete Enter key requirement", () => {
168
- // This test exists purely as documentation
169
- // The actual fix is in CodeEditor.tsx: completionStatus(state) check
170
- expect(true).toBe(true);
171
- });
172
- });
173
- });
@@ -1,35 +0,0 @@
1
- /**
2
- * Utilities for CodeEditor Enter key behavior.
3
- * Extracted for testability.
4
- */
5
-
6
- /**
7
- * Check if the cursor position is between matching brackets or tags.
8
- * Used to determine if Enter should "split" the brackets/tags.
9
- *
10
- * @param textBefore - Text before the cursor position
11
- * @param textAfter - Text after the cursor position
12
- * @returns true if cursor is between matching brackets/tags
13
- *
14
- * @example
15
- * // Returns true for:
16
- * // {|} - cursor between curly braces
17
- * // [|] - cursor between square brackets
18
- * // <tag>|</tag> - cursor between opening and closing tags
19
- */
20
- export function isBetweenBrackets(
21
- textBefore: string,
22
- textAfter: string,
23
- ): boolean {
24
- const charBefore = textBefore.slice(-1);
25
- const charAfter = textAfter.charAt(0);
26
-
27
- return (
28
- // Curly braces: {|}
29
- (charBefore === "{" && charAfter === "}") ||
30
- // Square brackets: [|]
31
- (charBefore === "[" && charAfter === "]") ||
32
- // XML/HTML tags: <tag>|</tag>
33
- (charBefore === ">" && charAfter === "<" && textAfter.startsWith("</"))
34
- );
35
- }
@@ -1,22 +0,0 @@
1
- export type { LanguageSupport, DecorationRange } from "./types";
2
- export {
3
- jsonLanguageSupport,
4
- isValidJsonTemplatePosition,
5
- calculateJsonIndentation,
6
- } from "./json";
7
- export {
8
- yamlLanguageSupport,
9
- isValidYamlTemplatePosition,
10
- calculateYamlIndentation,
11
- } from "./yaml";
12
- export {
13
- xmlLanguageSupport,
14
- isValidXmlTemplatePosition,
15
- calculateXmlIndentation,
16
- } from "./xml";
17
- export {
18
- markdownLanguageSupport,
19
- isValidMarkdownTemplatePosition,
20
- calculateMarkdownIndentation,
21
- } from "./markdown";
22
- export { isBetweenBrackets } from "./enterBehavior";
@@ -1,117 +0,0 @@
1
- /**
2
- * Pure utility functions for JSON parsing that don't depend on CodeMirror.
3
- * These are extracted to allow testing without triggering CodeMirror's module loading.
4
- */
5
-
6
- /**
7
- * Check if cursor is in a valid JSON template position.
8
- * Returns true if:
9
- * 1. Inside a string value (for string templates like "{{payload.name}}")
10
- * 2. In a value position after a colon but outside quotes (for number templates like {{payload.count}})
11
- * 3. Inside an array element position
12
- *
13
- * @internal Exported for testing
14
- */
15
- export function isValidJsonTemplatePosition(text: string): boolean {
16
- let insideString = false;
17
- let inValuePosition = false;
18
- // Stack to track object/array nesting: 'o' for object, 'a' for array
19
- const nestingStack: ("o" | "a")[] = [];
20
-
21
- for (let i = 0; i < text.length; i++) {
22
- const char = text[i];
23
- const prevChar = i > 0 ? text[i - 1] : "";
24
-
25
- if (char === '"' && prevChar !== "\\") {
26
- insideString = !insideString;
27
- } else if (!insideString) {
28
- switch (char) {
29
- case ":": {
30
- inValuePosition = true;
31
- break;
32
- }
33
- case ",": {
34
- // After comma: in arrays we stay in value position, in objects we go to key position
35
- const currentContext = nestingStack.at(-1);
36
- inValuePosition = currentContext === "a";
37
- break;
38
- }
39
- case "{": {
40
- // Start of object - next is a key position, not a value
41
- nestingStack.push("o");
42
- inValuePosition = false;
43
- break;
44
- }
45
- case "[": {
46
- // Start of array - next is a value position
47
- nestingStack.push("a");
48
- inValuePosition = true;
49
- break;
50
- }
51
- case "}": {
52
- nestingStack.pop();
53
- inValuePosition = false;
54
- break;
55
- }
56
- case "]": {
57
- nestingStack.pop();
58
- inValuePosition = false;
59
- break;
60
- }
61
- }
62
- }
63
- }
64
-
65
- // Valid if inside a string OR in a value position (for bare number templates)
66
- return insideString || inValuePosition;
67
- }
68
-
69
- /**
70
- * Calculate indentation for JSON, properly handling template expressions.
71
- * Counts structural { and [ while ignoring {{ template braces.
72
- * @internal Exported for testing
73
- */
74
- export function calculateJsonIndentation(
75
- textBefore: string,
76
- indentUnit: number,
77
- ): number {
78
- let depth = 0;
79
- let insideString = false;
80
- let i = 0;
81
-
82
- while (i < textBefore.length) {
83
- const char = textBefore[i];
84
- const nextChar = i < textBefore.length - 1 ? textBefore[i + 1] : "";
85
- const prevChar = i > 0 ? textBefore[i - 1] : "";
86
-
87
- // Handle string boundaries
88
- if (char === '"' && prevChar !== "\\") {
89
- insideString = !insideString;
90
- i++;
91
- continue;
92
- }
93
-
94
- if (!insideString) {
95
- // Skip template braces {{ and }}
96
- if (char === "{" && nextChar === "{") {
97
- i += 2;
98
- continue;
99
- }
100
- if (char === "}" && nextChar === "}") {
101
- i += 2;
102
- continue;
103
- }
104
-
105
- // Count structural braces
106
- if (char === "{" || char === "[") {
107
- depth++;
108
- } else if (char === "}" || char === "]") {
109
- depth = Math.max(0, depth - 1);
110
- }
111
- }
112
-
113
- i++;
114
- }
115
-
116
- return depth * indentUnit;
117
- }