@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
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,53 @@
|
|
|
1
1
|
# @checkstack/ui
|
|
2
2
|
|
|
3
|
+
## 0.3.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 83557c7: ## CodeEditor Multi-Language Support
|
|
8
|
+
|
|
9
|
+
- **Refactored CodeEditor** into modular architecture with language-specific support
|
|
10
|
+
- **Added language modes**: JSON, YAML, XML, and Markdown with custom indentation and syntax highlighting
|
|
11
|
+
- **Smart Enter key behavior**: Bracket/tag splitting (e.g., `<div></div>` → proper split on Enter)
|
|
12
|
+
- **Autocomplete fix**: Enter key now correctly selects completions instead of inserting newlines
|
|
13
|
+
- **Click area fix**: Entire editor area is now clickable (per official CodeMirror minHeight docs)
|
|
14
|
+
- **Line numbers**: Now visible with proper gutter styling
|
|
15
|
+
- **185 comprehensive tests** for all language indentation and template position validation
|
|
16
|
+
|
|
17
|
+
- 6dbfab8: Replace react-simple-code-editor with @uiw/react-codemirror for better maintenance and features. Added new `CodeEditor` component as a reusable abstraction for code editing with syntax highlighting.
|
|
18
|
+
|
|
19
|
+
### Patch Changes
|
|
20
|
+
|
|
21
|
+
- d316128: Add "None" option to optional Select fields in DynamicForm
|
|
22
|
+
|
|
23
|
+
**Bug Fix:**
|
|
24
|
+
|
|
25
|
+
- Optional select fields (using `x-options-resolver` or enums) now display a "None" option at the top of the dropdown
|
|
26
|
+
- Selecting "None" clears the field value, allowing users to unset previously selected values
|
|
27
|
+
- This fixes the issue where optional fields like `defaultRole` in authentication strategies could not be cleared after selection
|
|
28
|
+
|
|
29
|
+
- Updated dependencies [83557c7]
|
|
30
|
+
- @checkstack/common@0.4.0
|
|
31
|
+
- @checkstack/frontend-api@0.3.1
|
|
32
|
+
|
|
33
|
+
## 0.2.4
|
|
34
|
+
|
|
35
|
+
### Patch Changes
|
|
36
|
+
|
|
37
|
+
- d94121b: Add group-to-role mapping for SAML and LDAP authentication
|
|
38
|
+
|
|
39
|
+
**Features:**
|
|
40
|
+
|
|
41
|
+
- SAML and LDAP users can now be automatically assigned Checkstack roles based on their directory group memberships
|
|
42
|
+
- Configure group mappings in the authentication strategy settings with dynamic role dropdowns
|
|
43
|
+
- Managed role sync: roles configured in mappings are fully synchronized (added when user gains group, removed when user leaves group)
|
|
44
|
+
- Unmanaged roles (manually assigned, not in any mapping) are preserved during sync
|
|
45
|
+
- Optional default role for all users from a directory
|
|
46
|
+
|
|
47
|
+
**Bug Fix:**
|
|
48
|
+
|
|
49
|
+
- Fixed `x-options-resolver` not working for fields inside arrays with `.default([])` in DynamicForm schemas
|
|
50
|
+
|
|
3
51
|
## 0.2.3
|
|
4
52
|
|
|
5
53
|
### Patch Changes
|
package/package.json
CHANGED
|
@@ -1,26 +1,33 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@checkstack/ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "src/index.ts",
|
|
6
6
|
"dependencies": {
|
|
7
7
|
"@checkstack/common": "workspace:*",
|
|
8
8
|
"@checkstack/frontend-api": "workspace:*",
|
|
9
|
+
"@codemirror/autocomplete": "^6.20.0",
|
|
10
|
+
"@codemirror/lang-json": "^6.0.2",
|
|
11
|
+
"@codemirror/lang-markdown": "^6.5.0",
|
|
12
|
+
"@codemirror/lang-xml": "^6.1.0",
|
|
13
|
+
"@codemirror/lang-yaml": "^6.1.2",
|
|
14
|
+
"@codemirror/language": "^6.12.1",
|
|
15
|
+
"@codemirror/state": "^6.5.4",
|
|
16
|
+
"@codemirror/view": "^6.39.11",
|
|
9
17
|
"@radix-ui/react-accordion": "^1.2.12",
|
|
10
18
|
"@radix-ui/react-dialog": "^1.1.15",
|
|
11
19
|
"@radix-ui/react-select": "^2.2.6",
|
|
12
20
|
"@radix-ui/react-slot": "^1.2.4",
|
|
21
|
+
"@uiw/react-codemirror": "^4.25.4",
|
|
13
22
|
"ajv": "^8.17.1",
|
|
14
23
|
"ajv-formats": "^3.0.1",
|
|
15
24
|
"class-variance-authority": "^0.7.1",
|
|
16
25
|
"clsx": "^2.1.0",
|
|
17
26
|
"date-fns": "^4.1.0",
|
|
18
27
|
"lucide-react": "0.562.0",
|
|
19
|
-
"prismjs": "^1.29.0",
|
|
20
28
|
"react": "^18.2.0",
|
|
21
29
|
"react-markdown": "^10.1.0",
|
|
22
30
|
"react-router-dom": "^6.20.0",
|
|
23
|
-
"react-simple-code-editor": "^0.14.1",
|
|
24
31
|
"recharts": "^3.6.0",
|
|
25
32
|
"tailwind-merge": "^2.2.0"
|
|
26
33
|
},
|
|
@@ -0,0 +1,420 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import CodeMirror from "@uiw/react-codemirror";
|
|
3
|
+
import {
|
|
4
|
+
EditorView,
|
|
5
|
+
ViewPlugin,
|
|
6
|
+
Decoration,
|
|
7
|
+
keymap,
|
|
8
|
+
type ViewUpdate,
|
|
9
|
+
type DecorationSet,
|
|
10
|
+
} from "@codemirror/view";
|
|
11
|
+
import { RangeSetBuilder, Prec } from "@codemirror/state";
|
|
12
|
+
import {
|
|
13
|
+
autocompletion,
|
|
14
|
+
completionStatus,
|
|
15
|
+
type CompletionContext,
|
|
16
|
+
type CompletionResult,
|
|
17
|
+
} from "@codemirror/autocomplete";
|
|
18
|
+
import { indentUnit, getIndentUnit, indentString } from "@codemirror/language";
|
|
19
|
+
import {
|
|
20
|
+
jsonLanguageSupport,
|
|
21
|
+
yamlLanguageSupport,
|
|
22
|
+
xmlLanguageSupport,
|
|
23
|
+
markdownLanguageSupport,
|
|
24
|
+
isBetweenBrackets,
|
|
25
|
+
type LanguageSupport,
|
|
26
|
+
} from "./languageSupport";
|
|
27
|
+
|
|
28
|
+
export type CodeEditorLanguage = "json" | "yaml" | "xml" | "markdown";
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* A single payload property available for templating
|
|
32
|
+
*/
|
|
33
|
+
export interface TemplateProperty {
|
|
34
|
+
/** Full path to the property, e.g., "payload.incident.title" */
|
|
35
|
+
path: string;
|
|
36
|
+
/** Type of the property, e.g., "string", "number", "boolean" */
|
|
37
|
+
type: string;
|
|
38
|
+
/** Optional description of the property */
|
|
39
|
+
description?: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface CodeEditorProps {
|
|
43
|
+
/** Unique identifier for the editor */
|
|
44
|
+
id?: string;
|
|
45
|
+
/** Current value of the editor */
|
|
46
|
+
value: string;
|
|
47
|
+
/** Callback when the value changes */
|
|
48
|
+
onChange: (value: string) => void;
|
|
49
|
+
/** Language for syntax highlighting */
|
|
50
|
+
language?: CodeEditorLanguage;
|
|
51
|
+
/** Minimum height of the editor */
|
|
52
|
+
minHeight?: string;
|
|
53
|
+
/** Whether the editor is read-only */
|
|
54
|
+
readOnly?: boolean;
|
|
55
|
+
/** Placeholder text when empty */
|
|
56
|
+
placeholder?: string;
|
|
57
|
+
/**
|
|
58
|
+
* Optional template properties for autocomplete.
|
|
59
|
+
* When provided, typing "{{" triggers autocomplete with available template variables.
|
|
60
|
+
*/
|
|
61
|
+
templateProperties?: TemplateProperty[];
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Language support registry - add new languages here
|
|
65
|
+
const languageRegistry: Record<CodeEditorLanguage, LanguageSupport> = {
|
|
66
|
+
json: jsonLanguageSupport,
|
|
67
|
+
yaml: yamlLanguageSupport,
|
|
68
|
+
xml: xmlLanguageSupport,
|
|
69
|
+
markdown: markdownLanguageSupport,
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Get display type with color info for autocomplete
|
|
74
|
+
*/
|
|
75
|
+
function getTypeInfo(type: string): string {
|
|
76
|
+
return type.charAt(0).toUpperCase() + type.slice(1).toLowerCase();
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Create a ViewPlugin for template-aware syntax highlighting.
|
|
81
|
+
* Uses the language's buildDecorations function to generate decorations.
|
|
82
|
+
*/
|
|
83
|
+
function createTemplateHighlighter(languageSupport: LanguageSupport) {
|
|
84
|
+
return ViewPlugin.fromClass(
|
|
85
|
+
class {
|
|
86
|
+
decorations: DecorationSet;
|
|
87
|
+
|
|
88
|
+
constructor(view: EditorView) {
|
|
89
|
+
this.decorations = this.buildDecorations(view);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
buildDecorations(view: EditorView): DecorationSet {
|
|
93
|
+
const builder = new RangeSetBuilder<Decoration>();
|
|
94
|
+
const doc = view.state.doc.toString();
|
|
95
|
+
const ranges = languageSupport.buildDecorations(doc);
|
|
96
|
+
|
|
97
|
+
for (const range of ranges) {
|
|
98
|
+
builder.add(range.from, range.to, range.decoration);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return builder.finish();
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
update(update: ViewUpdate) {
|
|
105
|
+
if (update.docChanged || update.viewportChanged) {
|
|
106
|
+
this.decorations = this.buildDecorations(update.view);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
},
|
|
110
|
+
{
|
|
111
|
+
decorations: (v) => v.decorations,
|
|
112
|
+
},
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Create a CodeMirror autocomplete extension for template properties.
|
|
118
|
+
* Triggers when user types "{{" and offers completions from templateProperties.
|
|
119
|
+
*/
|
|
120
|
+
function createTemplateAutocomplete(
|
|
121
|
+
templateProperties: TemplateProperty[],
|
|
122
|
+
languageSupport: LanguageSupport,
|
|
123
|
+
): ReturnType<typeof autocompletion> {
|
|
124
|
+
return autocompletion({
|
|
125
|
+
override: [
|
|
126
|
+
(context: CompletionContext): CompletionResult | null => {
|
|
127
|
+
// Look for {{ pattern before cursor
|
|
128
|
+
const textBefore = context.state.sliceDoc(0, context.pos);
|
|
129
|
+
const recentText = context.state.sliceDoc(
|
|
130
|
+
Math.max(0, context.pos - 50),
|
|
131
|
+
context.pos,
|
|
132
|
+
);
|
|
133
|
+
|
|
134
|
+
// Find the last {{ that doesn't have a matching }}
|
|
135
|
+
const lastOpenBrace = recentText.lastIndexOf("{{");
|
|
136
|
+
const lastCloseBrace = recentText.lastIndexOf("}}");
|
|
137
|
+
|
|
138
|
+
if (lastOpenBrace === -1 || lastOpenBrace < lastCloseBrace) {
|
|
139
|
+
// eslint-disable-next-line unicorn/no-null -- CodeMirror API requires null
|
|
140
|
+
return null;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Validate position based on text BEFORE the {{ started
|
|
144
|
+
// We exclude the {{ from validation because it confuses most parsers
|
|
145
|
+
const positionOfTemplateStart =
|
|
146
|
+
context.pos - recentText.length + lastOpenBrace;
|
|
147
|
+
const textBeforeTemplate = textBefore.slice(0, positionOfTemplateStart);
|
|
148
|
+
if (!languageSupport.isValidTemplatePosition(textBeforeTemplate)) {
|
|
149
|
+
// eslint-disable-next-line unicorn/no-null -- CodeMirror API requires null
|
|
150
|
+
return null;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Calculate the position in the document where {{ starts
|
|
154
|
+
const startOffset = context.pos - recentText.length + lastOpenBrace;
|
|
155
|
+
const query = recentText.slice(lastOpenBrace + 2).toLowerCase();
|
|
156
|
+
|
|
157
|
+
// Check for auto-closed braces after cursor position
|
|
158
|
+
// When user types {{ with bracket auto-close, it becomes {{}}
|
|
159
|
+
// We need to consume any trailing }} when completing
|
|
160
|
+
const textAfter = context.state.sliceDoc(context.pos, context.pos + 4);
|
|
161
|
+
let endOffset = context.pos;
|
|
162
|
+
if (textAfter.startsWith("}}")) {
|
|
163
|
+
endOffset += 2;
|
|
164
|
+
} else if (textAfter.startsWith("}")) {
|
|
165
|
+
// Just one } from first auto-close
|
|
166
|
+
endOffset += 1;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Filter properties based on query
|
|
170
|
+
const filtered = templateProperties.filter((prop) =>
|
|
171
|
+
prop.path.toLowerCase().includes(query),
|
|
172
|
+
);
|
|
173
|
+
|
|
174
|
+
if (filtered.length === 0 && query.length > 0) {
|
|
175
|
+
// eslint-disable-next-line unicorn/no-null -- CodeMirror API requires null
|
|
176
|
+
return null;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
return {
|
|
180
|
+
from: startOffset,
|
|
181
|
+
to: endOffset,
|
|
182
|
+
options: filtered.map((prop) => ({
|
|
183
|
+
label: `{{${prop.path}}}`,
|
|
184
|
+
displayLabel: prop.path,
|
|
185
|
+
type: "variable",
|
|
186
|
+
detail: getTypeInfo(prop.type),
|
|
187
|
+
info: prop.description,
|
|
188
|
+
boost: prop.path.toLowerCase().startsWith(query) ? 1 : 0,
|
|
189
|
+
})),
|
|
190
|
+
validFor: /^\{\{[\w.]*$/,
|
|
191
|
+
};
|
|
192
|
+
},
|
|
193
|
+
],
|
|
194
|
+
activateOnTyping: true,
|
|
195
|
+
icons: false,
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* A code editor component with syntax highlighting and optional template autocomplete.
|
|
201
|
+
* Wraps @uiw/react-codemirror for consistent styling and API across the platform.
|
|
202
|
+
*/
|
|
203
|
+
export const CodeEditor: React.FC<CodeEditorProps> = ({
|
|
204
|
+
id,
|
|
205
|
+
value,
|
|
206
|
+
onChange,
|
|
207
|
+
language = "json",
|
|
208
|
+
minHeight = "100px",
|
|
209
|
+
readOnly = false,
|
|
210
|
+
placeholder,
|
|
211
|
+
templateProperties,
|
|
212
|
+
}) => {
|
|
213
|
+
const extensions = React.useMemo(() => {
|
|
214
|
+
const languageSupport = languageRegistry[language];
|
|
215
|
+
const hasTemplates = templateProperties && templateProperties.length > 0;
|
|
216
|
+
|
|
217
|
+
const exts = [
|
|
218
|
+
EditorView.lineWrapping,
|
|
219
|
+
EditorView.theme({
|
|
220
|
+
"&": {
|
|
221
|
+
fontSize: "14px",
|
|
222
|
+
fontFamily: "ui-monospace, monospace",
|
|
223
|
+
backgroundColor: "transparent",
|
|
224
|
+
},
|
|
225
|
+
".cm-scroller": {
|
|
226
|
+
backgroundColor: "transparent",
|
|
227
|
+
overflow: "auto",
|
|
228
|
+
},
|
|
229
|
+
// minHeight must be on .cm-content and .cm-gutter, not the wrapper (per CM docs)
|
|
230
|
+
".cm-content, .cm-gutter": {
|
|
231
|
+
minHeight: minHeight,
|
|
232
|
+
},
|
|
233
|
+
".cm-content": {
|
|
234
|
+
padding: "10px",
|
|
235
|
+
},
|
|
236
|
+
// Cursor/caret styling
|
|
237
|
+
".cm-cursor, .cm-dropCursor": {
|
|
238
|
+
borderLeftColor: "hsl(var(--foreground))",
|
|
239
|
+
borderLeftWidth: "2px",
|
|
240
|
+
},
|
|
241
|
+
".cm-line": {
|
|
242
|
+
color: "hsl(var(--foreground))",
|
|
243
|
+
},
|
|
244
|
+
".cm-gutters": {
|
|
245
|
+
backgroundColor: "transparent",
|
|
246
|
+
color: "hsl(var(--muted-foreground))",
|
|
247
|
+
border: "none",
|
|
248
|
+
},
|
|
249
|
+
"&.cm-focused": {
|
|
250
|
+
outline: "none",
|
|
251
|
+
},
|
|
252
|
+
// JSON syntax highlighting for dark/light mode
|
|
253
|
+
".cm-string": {
|
|
254
|
+
color: "hsl(142.1, 76.2%, 36.3%)",
|
|
255
|
+
},
|
|
256
|
+
".cm-number": {
|
|
257
|
+
color: "hsl(217.2, 91.2%, 59.8%)",
|
|
258
|
+
},
|
|
259
|
+
".cm-propertyName": {
|
|
260
|
+
color: "hsl(280, 65%, 60%)",
|
|
261
|
+
},
|
|
262
|
+
".cm-keyword": {
|
|
263
|
+
color: "hsl(280, 65%, 60%)",
|
|
264
|
+
},
|
|
265
|
+
".cm-punctuation": {
|
|
266
|
+
color: "hsl(var(--muted-foreground))",
|
|
267
|
+
},
|
|
268
|
+
// Placeholder styling
|
|
269
|
+
".cm-placeholder": {
|
|
270
|
+
color: "hsl(var(--muted-foreground))",
|
|
271
|
+
},
|
|
272
|
+
// JSON syntax highlighting via decorations (overrides Lezer parser)
|
|
273
|
+
".cm-json-property": {
|
|
274
|
+
color: "hsl(280, 65%, 60%)",
|
|
275
|
+
},
|
|
276
|
+
".cm-json-string": {
|
|
277
|
+
color: "hsl(142.1, 76.2%, 36.3%)",
|
|
278
|
+
},
|
|
279
|
+
".cm-json-number": {
|
|
280
|
+
color: "hsl(217.2, 91.2%, 59.8%)",
|
|
281
|
+
},
|
|
282
|
+
".cm-json-keyword": {
|
|
283
|
+
color: "hsl(280, 65%, 60%)",
|
|
284
|
+
},
|
|
285
|
+
// Template expression highlighting
|
|
286
|
+
".cm-template-expression": {
|
|
287
|
+
color: "hsl(190, 70%, 50%)",
|
|
288
|
+
fontWeight: "500",
|
|
289
|
+
},
|
|
290
|
+
// Style for autocomplete popup
|
|
291
|
+
".cm-tooltip.cm-tooltip-autocomplete": {
|
|
292
|
+
backgroundColor: "hsl(var(--popover))",
|
|
293
|
+
border: "1px solid hsl(var(--border))",
|
|
294
|
+
borderRadius: "0.375rem",
|
|
295
|
+
boxShadow:
|
|
296
|
+
"0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05)",
|
|
297
|
+
},
|
|
298
|
+
".cm-tooltip-autocomplete ul": {
|
|
299
|
+
fontFamily: "ui-monospace, monospace",
|
|
300
|
+
fontSize: "0.75rem",
|
|
301
|
+
},
|
|
302
|
+
".cm-tooltip-autocomplete ul li": {
|
|
303
|
+
padding: "0.25rem 0.5rem",
|
|
304
|
+
},
|
|
305
|
+
".cm-tooltip-autocomplete ul li[aria-selected]": {
|
|
306
|
+
backgroundColor: "hsl(var(--accent))",
|
|
307
|
+
color: "hsl(var(--accent-foreground))",
|
|
308
|
+
},
|
|
309
|
+
".cm-completionLabel": {
|
|
310
|
+
fontFamily: "ui-monospace, monospace",
|
|
311
|
+
},
|
|
312
|
+
".cm-completionDetail": {
|
|
313
|
+
marginLeft: "0.5rem",
|
|
314
|
+
fontStyle: "normal",
|
|
315
|
+
color: "hsl(var(--muted-foreground))",
|
|
316
|
+
},
|
|
317
|
+
}),
|
|
318
|
+
];
|
|
319
|
+
|
|
320
|
+
// Always add language extension for features (indentation, bracket matching, etc.)
|
|
321
|
+
if (languageSupport) {
|
|
322
|
+
exts.push(languageSupport.extension);
|
|
323
|
+
|
|
324
|
+
// Create custom Enter key handler that applies our indentation
|
|
325
|
+
// This is needed because the language parsers may get confused by templates
|
|
326
|
+
// or may not provide the indentation we want
|
|
327
|
+
const customEnterKeymap = keymap.of([
|
|
328
|
+
{
|
|
329
|
+
key: "Enter",
|
|
330
|
+
run: (view) => {
|
|
331
|
+
const state = view.state;
|
|
332
|
+
|
|
333
|
+
// If autocomplete is active, let it handle Enter for selection
|
|
334
|
+
if (completionStatus(state) === "active") {
|
|
335
|
+
return false;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
const pos = state.selection.main.head;
|
|
339
|
+
const textBefore = state.sliceDoc(0, pos);
|
|
340
|
+
const textAfter = state.sliceDoc(pos);
|
|
341
|
+
const unit = getIndentUnit(state);
|
|
342
|
+
const indent = languageSupport.calculateIndentation(
|
|
343
|
+
textBefore + "\n",
|
|
344
|
+
unit,
|
|
345
|
+
);
|
|
346
|
+
const indentStr = indentString(state, indent);
|
|
347
|
+
|
|
348
|
+
// Check if we're between matching brackets/tags
|
|
349
|
+
// This handles cases like: {|}, [|], <tag>|</tag>
|
|
350
|
+
if (isBetweenBrackets(textBefore, textAfter)) {
|
|
351
|
+
// Split: add newline with indent, then newline with previous indent for closing
|
|
352
|
+
const prevIndent = Math.max(0, indent - unit);
|
|
353
|
+
const prevIndentStr = indentString(state, prevIndent);
|
|
354
|
+
|
|
355
|
+
view.dispatch({
|
|
356
|
+
changes: {
|
|
357
|
+
from: pos,
|
|
358
|
+
to: pos,
|
|
359
|
+
insert: "\n" + indentStr + "\n" + prevIndentStr,
|
|
360
|
+
},
|
|
361
|
+
selection: { anchor: pos + 1 + indentStr.length },
|
|
362
|
+
scrollIntoView: true,
|
|
363
|
+
userEvent: "input",
|
|
364
|
+
});
|
|
365
|
+
} else {
|
|
366
|
+
// Normal: insert newline with calculated indentation
|
|
367
|
+
view.dispatch({
|
|
368
|
+
changes: { from: pos, to: pos, insert: "\n" + indentStr },
|
|
369
|
+
selection: { anchor: pos + 1 + indentStr.length },
|
|
370
|
+
scrollIntoView: true,
|
|
371
|
+
userEvent: "input",
|
|
372
|
+
});
|
|
373
|
+
}
|
|
374
|
+
return true;
|
|
375
|
+
},
|
|
376
|
+
},
|
|
377
|
+
]);
|
|
378
|
+
|
|
379
|
+
// Always add indentation support and custom highlighter for consistent behavior
|
|
380
|
+
// Prec.highest ensures our colors take precedence over language parser output
|
|
381
|
+
exts.push(
|
|
382
|
+
indentUnit.of(" "), // Configure 2-space indentation
|
|
383
|
+
Prec.highest(customEnterKeymap), // Override default Enter behavior
|
|
384
|
+
Prec.highest(createTemplateHighlighter(languageSupport)), // Consistent syntax colors
|
|
385
|
+
);
|
|
386
|
+
|
|
387
|
+
// Add template autocomplete if properties provided
|
|
388
|
+
if (hasTemplates) {
|
|
389
|
+
exts.push(
|
|
390
|
+
createTemplateAutocomplete(templateProperties, languageSupport),
|
|
391
|
+
);
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
return exts;
|
|
396
|
+
}, [language, templateProperties, minHeight]);
|
|
397
|
+
|
|
398
|
+
return (
|
|
399
|
+
<div
|
|
400
|
+
id={id}
|
|
401
|
+
className="w-full rounded-md border border-input bg-background font-mono text-sm focus-within:ring-2 focus-within:ring-ring focus-within:border-transparent transition-all box-border"
|
|
402
|
+
>
|
|
403
|
+
<CodeMirror
|
|
404
|
+
value={value}
|
|
405
|
+
onChange={onChange}
|
|
406
|
+
extensions={extensions}
|
|
407
|
+
editable={!readOnly}
|
|
408
|
+
placeholder={placeholder}
|
|
409
|
+
basicSetup={{
|
|
410
|
+
lineNumbers: true,
|
|
411
|
+
foldGutter: false,
|
|
412
|
+
highlightActiveLine: false,
|
|
413
|
+
highlightSelectionMatches: false,
|
|
414
|
+
autocompletion: false, // We use our own
|
|
415
|
+
}}
|
|
416
|
+
theme="none"
|
|
417
|
+
/>
|
|
418
|
+
</div>
|
|
419
|
+
);
|
|
420
|
+
};
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export {
|
|
2
|
+
CodeEditor,
|
|
3
|
+
type CodeEditorProps,
|
|
4
|
+
type CodeEditorLanguage,
|
|
5
|
+
type TemplateProperty,
|
|
6
|
+
} from "./CodeEditor";
|
|
7
|
+
|
|
8
|
+
// Re-export language support for testing and extensibility
|
|
9
|
+
export { isValidJsonTemplatePosition } from "./languageSupport";
|
|
10
|
+
export type { LanguageSupport, DecorationRange } from "./languageSupport";
|
|
@@ -0,0 +1,173 @@
|
|
|
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
|
+
});
|