Skip to content

Commit a8732fd

Browse files
gribnoysupaddaleax
andauthored
chore(compass-editor): add document autocompleter COMPASS-6571 (#4125)
* chore(compass-editor): add document autocompleter * chore(compass-editor): better variable name Co-authored-by: Anna Henningsen <[email protected]> * chore(compass-crud): fix component types * chore(compass-crud): fix test assertion --------- Co-authored-by: Anna Henningsen <[email protected]>
1 parent fe181ee commit a8732fd

File tree

8 files changed

+127
-33
lines changed

8 files changed

+127
-33
lines changed

packages/compass-crud/src/components/document-json-view.tsx

Lines changed: 6 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,8 @@
11
import React from 'react';
2-
import PropTypes from 'prop-types';
32
import { css, cx, KeylineCard } from '@mongodb-js/compass-components';
43

5-
import type { JsonEditorProps } from './json-editor';
6-
import JsonEditor from './json-editor';
4+
import type { JSONEditorProps } from './json-editor';
5+
import JSONEditor from './json-editor';
76
import type Document from 'hadron-document';
87

98
/**
@@ -26,14 +25,15 @@ export type DocumentJsonViewProps = {
2625
isEditable: boolean;
2726
className?: string;
2827
} & Pick<
29-
JsonEditorProps,
28+
JSONEditorProps,
3029
| 'isTimeSeries'
3130
| 'copyToClipboard'
3231
| 'removeDocument'
3332
| 'replaceDocument'
3433
| 'updateDocument'
3534
| 'openInsertDocumentDialog'
3635
| 'isExpanded'
36+
| 'fields'
3737
>;
3838

3939
const keylineCardCSS = css({
@@ -56,7 +56,7 @@ class DocumentJsonView extends React.Component<DocumentJsonViewProps> {
5656
return (
5757
<li className={LIST_ITEM_CLASS} data-testid={LIST_ITEM_TEST_ID} key={i}>
5858
<KeylineCard className={keylineCardCSS}>
59-
<JsonEditor
59+
<JSONEditor
6060
key={doc.uuid}
6161
doc={doc}
6262
editable={this.props.isEditable}
@@ -67,6 +67,7 @@ class DocumentJsonView extends React.Component<DocumentJsonViewProps> {
6767
updateDocument={this.props.updateDocument}
6868
openInsertDocumentDialog={this.props.openInsertDocumentDialog}
6969
isExpanded={this.props.isExpanded}
70+
fields={this.props.fields}
7071
/>
7172
</KeylineCard>
7273
</li>
@@ -86,21 +87,6 @@ class DocumentJsonView extends React.Component<DocumentJsonViewProps> {
8687
</ol>
8788
);
8889
}
89-
90-
static propTypes = {
91-
docs: PropTypes.array.isRequired,
92-
isEditable: PropTypes.bool,
93-
isTimeSeries: PropTypes.bool,
94-
removeDocument: PropTypes.func,
95-
replaceDocument: PropTypes.func,
96-
updateDocument: PropTypes.func,
97-
openInsertDocumentDialog: PropTypes.func,
98-
copyToClipboard: PropTypes.func,
99-
isExpanded: PropTypes.bool,
100-
className: PropTypes.string,
101-
};
102-
103-
static displayName = 'DocumentJsonView';
10490
}
10591

10692
export default DocumentJsonView;

packages/compass-crud/src/components/json-editor.tsx

Lines changed: 24 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,10 @@
1-
import React, { useCallback, useEffect, useState, useRef } from 'react';
1+
import React, {
2+
useCallback,
3+
useEffect,
4+
useState,
5+
useRef,
6+
useMemo,
7+
} from 'react';
28
import {
39
css,
410
cx,
@@ -10,7 +16,10 @@ import {
1016
import type { Document } from 'hadron-document';
1117
import HadronDocument from 'hadron-document';
1218

13-
import { JSONEditor as Editor } from '@mongodb-js/compass-editor';
19+
import {
20+
createDocumentAutocompleter,
21+
JSONEditor as Editor,
22+
} from '@mongodb-js/compass-editor';
1423
import type { EditorView } from '@mongodb-js/compass-editor';
1524
import type { CrudActions } from '../stores/crud-store';
1625

@@ -35,27 +44,29 @@ const editorDarkModeStyles = css({
3544
},
3645
});
3746

38-
export type JsonEditorProps = {
47+
export type JSONEditorProps = {
3948
doc: Document;
4049
editable: boolean;
41-
isTimeSeries: boolean;
50+
isTimeSeries?: boolean;
4251
removeDocument?: CrudActions['removeDocument'];
4352
replaceDocument?: CrudActions['replaceDocument'];
4453
updateDocument?: CrudActions['updateDocument'];
4554
copyToClipboard?: CrudActions['copyToClipboard'];
4655
openInsertDocumentDialog?: CrudActions['openInsertDocumentDialog'];
47-
isExpanded: boolean;
56+
isExpanded?: boolean;
57+
fields?: string[];
4858
};
4959

50-
const JSONEditor: React.FunctionComponent<JsonEditorProps> = ({
60+
const JSONEditor: React.FunctionComponent<JSONEditorProps> = ({
5161
doc,
5262
editable,
53-
isTimeSeries,
63+
isTimeSeries = false,
5464
removeDocument,
5565
replaceDocument,
5666
copyToClipboard,
5767
openInsertDocumentDialog,
58-
isExpanded,
68+
isExpanded = false,
69+
fields = [],
5970
}) => {
6071
const darkMode = useDarkMode();
6172
const editorRef = useRef<EditorView>();
@@ -140,6 +151,10 @@ const JSONEditor: React.FunctionComponent<JsonEditorProps> = ({
140151
}
141152
}, [isExpanded]);
142153

154+
const completer = useMemo(() => {
155+
return createDocumentAutocompleter(fields);
156+
}, [fields]);
157+
143158
const isEditable = editable && !deleting && !isTimeSeries;
144159

145160
return (
@@ -153,6 +168,7 @@ const JSONEditor: React.FunctionComponent<JsonEditorProps> = ({
153168
onLoad={(editor) => {
154169
editorRef.current = editor;
155170
}}
171+
completer={completer}
156172
/>
157173
{!editing && (
158174
<DocumentList.DocumentActionsGroup

packages/compass-crud/src/stores/crud-store.spec.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -238,6 +238,7 @@ describe('store', function () {
238238
},
239239
version: '6.0.0',
240240
view: 'List',
241+
fields: [],
241242
});
242243
});
243244
});

packages/compass-crud/src/stores/crud-store.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -321,6 +321,7 @@ type CrudState = {
321321
resultId: number;
322322
isWritable: boolean;
323323
instanceDescription: string;
324+
fields: string[];
324325
};
325326

326327
class CrudStoreImpl
@@ -343,6 +344,10 @@ class CrudStoreImpl
343344
this.listenables = options.actions as any; // TODO: The types genuinely mismatch here
344345
}
345346

347+
updateFields(fields: { aceFields: { name: string }[] }) {
348+
this.setState({ fields: fields.aceFields.map((field) => field.name) });
349+
}
350+
346351
getInitialState(): CrudState {
347352
return {
348353
ns: '',
@@ -371,6 +376,7 @@ class CrudStoreImpl
371376
resultId: resultId(),
372377
isWritable: false,
373378
instanceDescription: '',
379+
fields: [],
374380
};
375381
}
376382

@@ -1457,6 +1463,8 @@ const configureStore = (options: CrudStoreOptions & GridStoreOptions) => {
14571463
// eslint-disable-next-line @typescript-eslint/no-misused-promises
14581464
localAppRegistry.on('refresh-data', store.refreshDocuments.bind(store));
14591465

1466+
localAppRegistry.on('fields-changed', store.updateFields.bind(store));
1467+
14601468
setLocalAppRegistry(store, options.localAppRegistry);
14611469
}
14621470

packages/compass-editor/src/autocompleter.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -114,10 +114,10 @@ function isValidIdentifier(identifier: string) {
114114
* Helper method to conditionally wrap completion value if it's not a valid
115115
* identifier
116116
*/
117-
export function wrapField(field: string) {
118-
return isValidIdentifier(field)
119-
? field
120-
: `"${field.replace(/["\\]/g, '\\$&')}"`;
117+
export function wrapField(field: string, force = false) {
118+
return force || !isValidIdentifier(field)
119+
? `"${field.replace(/["\\]/g, '\\$&')}"`
120+
: field;
121121
}
122122

123123
export function completer(
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
import type {
2+
CompletionContext,
3+
CompletionSource,
4+
} from '@codemirror/autocomplete';
5+
import { syntaxTree } from '@codemirror/language';
6+
import { completeAnyWord, ifIn } from '@codemirror/autocomplete';
7+
import { completer, wrapField } from '../autocompleter';
8+
import { languageName } from '../json-editor';
9+
10+
const completeWordsInString = ifIn(['String'], completeAnyWord);
11+
12+
function resolveTokenAtCursor(context: CompletionContext) {
13+
return syntaxTree(context.state).resolveInner(context.pos, -1);
14+
}
15+
16+
type Token = ReturnType<typeof resolveTokenAtCursor>;
17+
18+
function isJSONPropertyName(token: Token): boolean {
19+
return (
20+
// Cursor is currently on the valid property name in the object
21+
token.name === 'PropertyName' ||
22+
// Cursor is possibly on the invalid property name as indicated by the
23+
// previous sibling being a property or an open bracket and not a
24+
// `PropertyName`, which would be the case for property value
25+
(token.type.isError &&
26+
['Property', '{'].includes(token.prevSibling?.name ?? ''))
27+
);
28+
}
29+
function isJavaScriptPropertyName(token: Token): boolean {
30+
return (
31+
// Cursor is currently inside a property
32+
token.parent?.name === 'Property' &&
33+
// There is no previous sibling or it's an opening bracket (indicating
34+
// computed property)
35+
(!token.prevSibling || token.prevSibling.name === '[')
36+
);
37+
}
38+
39+
/**
40+
* Autocompleter for the document object, only autocompletes field names in the
41+
* appropriate format (either escaped or not) both for javascript and json modes
42+
*/
43+
export const createDocumentAutocompleter = (
44+
fields: string[]
45+
): CompletionSource => {
46+
const completions = completer('', { fields, meta: ['field:identifier'] });
47+
48+
return (context) => {
49+
const token = resolveTokenAtCursor(context);
50+
51+
const shouldAlwaysEscapeProperty =
52+
context.state.facet(languageName)[0] === 'json';
53+
54+
if (isJSONPropertyName(token) || isJavaScriptPropertyName(token)) {
55+
const prefix = context.state
56+
.sliceDoc(token.from, context.pos)
57+
.replace(/^("|')/, '');
58+
59+
return {
60+
from: token.from,
61+
to: token.to,
62+
options: completions
63+
.filter((completion) => {
64+
return completion.value
65+
.toLowerCase()
66+
.startsWith(prefix.toLowerCase());
67+
})
68+
.map((completion) => {
69+
return {
70+
label: wrapField(completion.value, shouldAlwaysEscapeProperty),
71+
// https://codemirror.net/docs/ref/#autocomplete.Completion.type
72+
type: 'property',
73+
detail: 'field',
74+
};
75+
}),
76+
filter: false,
77+
};
78+
}
79+
80+
return completeWordsInString(context);
81+
};
82+
};

packages/compass-editor/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,3 +14,4 @@ export { Editor } from './multiline-editor';
1414
export { JSONEditor } from './json-editor';
1515
export type { EditorView } from './json-editor';
1616
export { SyntaxHighlight } from './syntax-highlight';
17+
export { createDocumentAutocompleter } from './codemirror/document-autocompleter';

packages/compass-editor/src/json-editor.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -355,7 +355,7 @@ const languages: Record<EditorLanguage, () => LanguageSupport> = {
355355
},
356356
};
357357

358-
const languageName = Facet.define<EditorLanguage>({});
358+
export const languageName = Facet.define<EditorLanguage>({});
359359

360360
/**
361361
* https://codemirror.net/examples/config/#dynamic-configuration

0 commit comments

Comments
 (0)