Skip to content

Commit e71931d

Browse files
authored
Merge pull request #116 from FlowTestAI/minor-improvements-2
feat: use rich editor for variables auto suggestion and usage highlighting
2 parents f32673d + 912483f commit e71931d

15 files changed

Lines changed: 489 additions & 145 deletions

File tree

.changeset/three-toes-brush.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'flowtestai': minor
3+
---
4+
5+
use rich editor for auto complete variables and redefine UI of request nodes

packages/flowtest-electron/src/utils/collection.js

Lines changed: 28 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -27,9 +27,13 @@ const parseOpenAPISpec = (collection) => {
2727
let parsedNodes = [];
2828
try {
2929
// servers is array,, figure case where there can be multiple servers
30-
const baseUrl = collection['servers'][0]['url'];
31-
Object.entries(collection['paths']).map(([path, operation], _) => {
32-
Object.entries(operation).map(([requestType, request], _) => {
30+
const baseUrl = collection['servers'].length > 1 ? '{baseUrl}' : collection['servers'][0]['url'];
31+
Object.entries(collection['paths']).map(([path, operations], _) => {
32+
const commonParameters = Object.prototype.hasOwnProperty.call(operations, 'parameters')
33+
? operations['parameters']
34+
: [];
35+
const { parameters, ...operationsFiltered } = operations;
36+
Object.entries(operationsFiltered).map(([requestType, request], _) => {
3337
const summary = request['summary'];
3438
const operationId = request['operationId'];
3539
const tags = request['tags'];
@@ -39,9 +43,29 @@ const parseOpenAPISpec = (collection) => {
3943
const pathParameters = [];
4044
const queryParameters = [];
4145

46+
const requestParameters = commonParameters.map((obj) => {
47+
if (request['parameters']) {
48+
// Find the object in the second array that has the same id as the current object
49+
const objFromArr2 = request['parameters'].find((o) => o.name === obj.name && o.in === obj.in);
50+
// If found, merge the two objects, otherwise return the original object
51+
return objFromArr2 ? { ...obj, ...objFromArr2 } : obj;
52+
} else {
53+
return obj;
54+
}
55+
});
56+
4257
if (request['parameters']) {
58+
// Add any objects from the second array that do not exist in the first array
59+
request['parameters'].forEach((obj) => {
60+
if (!commonParameters.some((o) => o.name === obj.name && o.in === obj.in)) {
61+
requestParameters.push(obj);
62+
}
63+
});
64+
}
65+
66+
if (requestParameters.length > 0) {
4367
let firstQueryParam = true;
44-
request['parameters'].map((value, _) => {
68+
requestParameters.map((value, _) => {
4569
if (value['in'] === 'query') {
4670
if (firstQueryParam) {
4771
url = url.concat(`?${value['name']}={{${value['name']}}}`);

src/components/atoms/Editor.js

Lines changed: 47 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,58 @@
11
import React, { useRef, useEffect, useState } from 'react';
22

33
import { basicSetup } from 'codemirror';
4-
import { EditorState, Prec } from '@codemirror/state';
5-
import { EditorView, keymap, lineNumbers } from '@codemirror/view';
4+
import { EditorState, Compartment } from '@codemirror/state';
5+
import { EditorView, keymap, lineNumbers, tooltips } from '@codemirror/view';
66
import { indentWithTab, history } from '@codemirror/commands';
77
import { json } from '@codemirror/lang-json';
88
import { defaultKeymap } from '@codemirror/commands';
99
import { syntaxHighlighting, defaultHighlightStyle } from '@codemirror/language';
10+
import { isEqual } from 'lodash';
11+
import { autocompletion, CompletionContext } from '@codemirror/autocomplete';
12+
13+
// Function to dynamically generate autocomplete options
14+
const createAutocompleteSource = (options) => {
15+
return (context) => {
16+
let word = context.matchBefore(/\{\{\w*$/);
17+
if (!word) return null;
18+
19+
return {
20+
from: word.from,
21+
options: options.map((option) => ({ label: `{{${option}}}`, type: 'keyword' })),
22+
};
23+
};
24+
};
25+
26+
// Create a Compartment for autocomplete
27+
const autocompleteCompartment = new Compartment();
28+
29+
// Function to update autocomplete options
30+
const updateAutocompleteOptions = (view, newOptions) => {
31+
view.dispatch({
32+
effects: autocompleteCompartment.reconfigure(autocompletion({ override: [createAutocompleteSource(newOptions)] })),
33+
});
34+
};
35+
36+
// Custom styles to hide scrollbar
37+
const hideScrollbar = EditorView.theme({
38+
'.cm-scroller': {
39+
overflowX: 'auto',
40+
overflowY: 'hidden',
41+
whiteSpace: 'nowrap',
42+
scrollbarWidth: 'none' /* For Firefox */,
43+
},
44+
});
1045

1146
export const Editor = ({ ...props }) => {
1247
const editor = useRef();
1348
const [view, setView] = useState(null);
49+
const [dynamicOptions, setDynamicOptions] = useState([]);
1450

1551
if (view) {
52+
if (!isEqual(dynamicOptions, props.completionOptions)) {
53+
updateAutocompleteOptions(view, props.completionOptions);
54+
setDynamicOptions(props.completionOptions);
55+
}
1656
if (props.value != view.state.doc.toString()) {
1757
view.dispatch({ changes: { from: 0, to: view.state.doc.length, insert: props.value } });
1858
}
@@ -35,6 +75,11 @@ export const Editor = ({ ...props }) => {
3575
onUpdate,
3676
EditorState.readOnly.of(props.readOnly || false),
3777
history(),
78+
hideScrollbar,
79+
autocompleteCompartment.of(autocompletion({ override: [createAutocompleteSource(dynamicOptions)] })),
80+
tooltips({
81+
parent: document.body,
82+
}),
3883
],
3984
});
4085

Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
import React, { useRef, useEffect, useState } from 'react';
2+
3+
import { basicSetup } from 'codemirror';
4+
import { EditorState, Compartment } from '@codemirror/state';
5+
import { EditorView, keymap, lineNumbers, placeholder, Decoration, ViewPlugin, MatchDecorator } from '@codemirror/view';
6+
import { indentWithTab, history } from '@codemirror/commands';
7+
//import { json } from '@codemirror/lang-json';
8+
import { defaultKeymap } from '@codemirror/commands';
9+
import { syntaxHighlighting, defaultHighlightStyle, HighlightStyle, StreamLanguage } from '@codemirror/language';
10+
import { autocompletion, CompletionContext } from '@codemirror/autocomplete';
11+
import { tags, styleTags } from '@lezer/highlight';
12+
import { isEqual } from 'lodash';
13+
14+
// Function to dynamically generate autocomplete options
15+
const createAutocompleteSource = (options) => {
16+
return (context) => {
17+
let word = context.matchBefore(/\{\{\w*$/);
18+
if (!word) return null;
19+
20+
return {
21+
from: word.from,
22+
options: options.map((option) => ({ label: `{{${option}}}`, type: 'keyword' })),
23+
};
24+
};
25+
};
26+
27+
// Create a Compartment for autocomplete
28+
const autocompleteCompartment = new Compartment();
29+
30+
// Function to update autocomplete options
31+
const updateAutocompleteOptions = (view, newOptions) => {
32+
view.dispatch({
33+
effects: autocompleteCompartment.reconfigure(autocompletion({ override: [createAutocompleteSource(newOptions)] })),
34+
});
35+
};
36+
37+
// Custom styles to hide scrollbar
38+
const hideScrollbar = EditorView.theme({
39+
'.cm-scroller': {
40+
overflowX: 'auto',
41+
overflowY: 'hidden',
42+
whiteSpace: 'nowrap',
43+
scrollbarWidth: 'none' /* For Firefox */,
44+
},
45+
'.cm-content': {
46+
padding: '0', // Adjust padding to fit your needs
47+
overflow: 'auto',
48+
},
49+
'.cm-scroller::-webkit-scrollbar': {
50+
display: 'none' /* For Chrome, Safari, and Opera */,
51+
},
52+
'.cm-line': {
53+
padding: '0', // Adjust padding to fit your needs
54+
},
55+
'&': {
56+
height: 'auto', // Adjust height to auto for single-line input
57+
},
58+
'.cm-placeholder': {
59+
color: '#aaa', // Placeholder text color
60+
},
61+
});
62+
63+
// Rebind the Enter key to do nothing
64+
const rebindEnterKey = keymap.of([
65+
{
66+
key: 'Enter',
67+
run: () => true, // Return true to prevent the default action
68+
},
69+
]);
70+
71+
// Create a MatchDecorator to highlight {{text}} strings
72+
const decorator = new MatchDecorator({
73+
// Regular expression to match {{text}} strings
74+
regexp: /{{[^}]*}}/g,
75+
decoration: Decoration.mark({ class: 'highlight' }),
76+
});
77+
78+
// View plugin to apply the MatchDecorator
79+
const highlightPlugin = ViewPlugin.fromClass(
80+
class {
81+
constructor(view) {
82+
this.decorations = decorator.createDeco(view);
83+
}
84+
update(update) {
85+
this.decorations = decorator.updateDeco(update, this.decorations);
86+
}
87+
},
88+
{
89+
decorations: (v) => v.decorations,
90+
},
91+
);
92+
93+
// Custom styles for highlighting
94+
const highlightStyle = EditorView.baseTheme({
95+
'.highlight': {
96+
color: 'brown',
97+
},
98+
});
99+
100+
export const TextEditor = ({ placeHolder, onChangeHandler, value, disableState, completionOptions, styles }) => {
101+
const editor1 = useRef();
102+
const [view, setView] = useState(null);
103+
const [dynamicOptions, setDynamicOptions] = useState([]);
104+
105+
if (view) {
106+
if (!isEqual(dynamicOptions, completionOptions)) {
107+
updateAutocompleteOptions(view, completionOptions);
108+
setDynamicOptions(completionOptions);
109+
}
110+
if (value != view.state.doc.toString()) {
111+
view.dispatch({ changes: { from: 0, to: view.state.doc.length, insert: value } });
112+
}
113+
}
114+
115+
const onUpdate = EditorView.updateListener.of((v) => {
116+
if (onChangeHandler) {
117+
onChangeHandler(v.state.doc.toString());
118+
}
119+
});
120+
121+
useEffect(() => {
122+
const state = EditorState.create({
123+
doc: value,
124+
extensions: [
125+
//EditorView.lineWrapping,
126+
placeholder(placeHolder),
127+
//myAutocomplete,
128+
//basicSetup,
129+
keymap.of([defaultKeymap, indentWithTab]),
130+
onUpdate,
131+
//EditorState.readOnly.of(props.readOnly || false),
132+
history(),
133+
hideScrollbar,
134+
rebindEnterKey,
135+
highlightPlugin,
136+
highlightStyle,
137+
autocompleteCompartment.of(autocompletion({ override: [createAutocompleteSource(dynamicOptions)] })),
138+
],
139+
});
140+
141+
const view = new EditorView({ state, parent: editor1.current });
142+
setView(view);
143+
144+
return () => {
145+
view.destroy();
146+
setView(null);
147+
};
148+
}, []);
149+
150+
const mainStyles =
151+
'nodrag nowheel block rounded border border-slate-700 bg-background-light p-2.5 text-sm outline-none';
152+
const intentStyles = disableState ? 'cursor-not-allowed text-slate-400' : 'text-slate-900';
153+
154+
return <div ref={editor1} className={`${mainStyles} ${intentStyles} ${styles}`}></div>;
155+
};

src/components/atoms/flow/FlowNode.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ const FlowNode = ({ children, title, handleLeft, handleLeftData, handleRight, ha
99
<div
1010
className={`${
1111
children ? 'flex-col' : 'items-center justify-center px-6 py-4'
12-
} bg-background-lighter flex rounded-md border-2 border-slate-300`}
12+
} flex rounded-md border-2 border-slate-300 bg-background-lighter`}
1313
>
1414
{children ? (
1515
<>

src/components/molecules/flow/nodes/AuthNode.js

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,10 @@ import useCanvasStore from 'stores/CanvasStore';
55
import { Listbox, Transition } from '@headlessui/react';
66
import { CheckIcon, ChevronUpDownIcon } from '@heroicons/react/20/solid';
77
import TextInput from 'components/atoms/common/TextInput';
8+
import { TextEditor } from 'components/atoms/common/TextEditor';
9+
import useCollectionStore from 'stores/CollectionStore';
10+
import { useTabStore } from 'stores/TabStore';
11+
import { cloneDeep } from 'lodash';
812

913
const AuthNode = ({ id, data }) => {
1014
const setAuthNodeType = useCanvasStore((state) => state.setAuthNodeType);
@@ -15,6 +19,20 @@ const AuthNode = ({ id, data }) => {
1519
setBasicAuthValues(id, option, value);
1620
};
1721

22+
const getActiveVariables = () => {
23+
const collectionId = useCanvasStore.getState().collectionId;
24+
if (collectionId) {
25+
const activeEnv = useCollectionStore
26+
.getState()
27+
.collections.find((c) => c.id === collectionId)
28+
?.environments.find((e) => e.name === useTabStore.getState().selectedEnv);
29+
if (activeEnv) {
30+
return Object.keys(cloneDeep(activeEnv.variables));
31+
}
32+
}
33+
return [];
34+
};
35+
1836
return (
1937
<>
2038
<FlowNode
@@ -89,17 +107,21 @@ const AuthNode = ({ id, data }) => {
89107
</Listbox>
90108
{data.type === 'basic-auth' && (
91109
<div className='flex flex-col gap-2 py-4'>
92-
<TextInput
110+
<TextEditor
93111
placeHolder={`Username`}
94-
onChangeHandler={(e) => handleChange(e.target.value, 'username')}
112+
onChangeHandler={(value) => handleChange(value, 'username')}
95113
name={'username'}
96114
value={data.username ? data.username : ''}
115+
completionOptions={getActiveVariables()}
116+
styles={'w-full'}
97117
/>
98-
<TextInput
118+
<TextEditor
99119
placeHolder={`Password`}
100-
onChangeHandler={(e) => handleChange(e.target.value, 'password')}
120+
onChangeHandler={(value) => handleChange(value, 'password')}
101121
name={'username'}
102122
value={data.password ? data.password : ''}
123+
completionOptions={getActiveVariables()}
124+
styles={'w-full'}
103125
/>
104126
</div>
105127
)}

src/components/molecules/flow/nodes/OutputNode.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,13 +27,13 @@ const OutputNode = ({ id, data }) => {
2727
) : (
2828
<></>
2929
)}
30-
<div className='w-full text-xs text-gray-900 border border-gray-300 rounded-lg nodrag nowheel min-w-72 bg-gray-50 outline-blue-300 focus:border-blue-100 focus:ring-blue-100'>
30+
<div className='text-xs text-gray-900 border border-gray-300 rounded-lg nodrag nowheel min-w-72 max-w-96 bg-gray-50 outline-blue-300 focus:border-blue-100 focus:ring-blue-100'>
3131
{data.output ? (
3232
<Editor
3333
name='output-text'
3434
value={JSON.stringify(data.output, null, 2)}
3535
readOnly={true}
36-
classes={'w-96 h-96'}
36+
classes={'w-full max-h-96'}
3737
/>
3838
) : (
3939
<div className='p-2'>{'Run flow to see data'}</div>

0 commit comments

Comments
 (0)