Skip to content

Commit b103c12

Browse files
committed
Implement JSON formatting and syntax highlighting
Enhance UX in Dashboard with JSON text editor using Monaco Editor (@monaco-editor/react) for improved formatting, syntax highlighting, and validation error reporting. Signed-off-by: AmerMesanovic <amer.mesanovic@secomind.com>
1 parent 9ebb871 commit b103c12

File tree

9 files changed

+331
-32
lines changed

9 files changed

+331
-32
lines changed

package-lock.json

Lines changed: 40 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@
4949
"@emotion/react": "^11.14.0",
5050
"@emotion/styled": "^11.14.0",
5151
"@fortawesome/fontawesome-free": "^6.7.2",
52+
"@monaco-editor/react": "^4.6.0",
5253
"@projectstorm/react-canvas-core": "^7.0.2",
5354
"@projectstorm/react-diagrams": "^7.0.4",
5455
"@reduxjs/toolkit": "^2.5.0",

src/App.tsx

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import AstarteProvider, { useAstarte } from './AstarteManager';
3030
import type { DashboardConfig } from './types';
3131
import Snackbar from './ui/Snackbar';
3232
import createReduxStore from './store';
33+
import { MonacoProvider } from 'components/MonacoContext';
3334

3435
const DashboardSidebar = () => {
3536
const config = useConfig();
@@ -142,15 +143,17 @@ interface Props {
142143
export default ({ config }: Props): React.ReactElement => (
143144
<AlertsProvider>
144145
<RouterProvider>
145-
{config ? (
146-
<ConfigProvider config={config}>
147-
<AstarteProvider config={config}>
148-
<Dashboard />
149-
</AstarteProvider>
150-
</ConfigProvider>
151-
) : (
152-
<StandaloneEditor />
153-
)}
146+
<MonacoProvider>
147+
{config ? (
148+
<ConfigProvider config={config}>
149+
<AstarteProvider config={config}>
150+
<Dashboard />
151+
</AstarteProvider>
152+
</ConfigProvider>
153+
) : (
154+
<StandaloneEditor />
155+
)}
156+
</MonacoProvider>
154157
</RouterProvider>
155158
<Snackbar />
156159
</AlertsProvider>

src/components/InterfaceEditor.tsx

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,8 @@ import _ from 'lodash';
3535

3636
import Icon from './Icon';
3737
import MappingEditor from './MappingEditor';
38+
import JsonEditor from './JsonEditor';
39+
import { AstarteInterfaceSchema } from '../schemas/jsonSchemas';
3840

3941
interface FormControlWarningProps {
4042
message?: string;
@@ -777,7 +779,7 @@ export default ({
777779

778780
return (
779781
<Row>
780-
<Col md={isSourceVisible ? 6 : 12}>
782+
<Col md={isSourceVisible ? 6 : 12} className="z-1">
781783
<Container fluid className="bg-white rounded p-3">
782784
<Form>
783785
<Stack gap={3}>
@@ -1103,16 +1105,22 @@ export default ({
11031105
<Col md={6}>
11041106
<Form.Group controlId="interfaceSource" className="h-100 d-flex flex-column">
11051107
<Form.Control
1106-
as="textarea"
1107-
className="flex-grow-1 font-monospace"
1108-
value={interfaceSource}
1109-
onChange={handleInterfaceSourceChange}
1108+
as="div"
11101109
autoComplete="off"
11111110
required
11121111
isValid={isValidInterfaceSource}
11131112
isInvalid={!isValidInterfaceSource}
1114-
/>
1115-
<Form.Control.Feedback type="invalid">{interfaceSourceError}</Form.Control.Feedback>
1113+
className="flex-grow-1 font-monospace"
1114+
>
1115+
<JsonEditor
1116+
resource={AstarteInterfaceSchema}
1117+
value={interfaceSource}
1118+
onChange={handleInterfaceSourceChange}
1119+
/>
1120+
</Form.Control>
1121+
{!isValidInterfaceSource && (
1122+
<Form.Control.Feedback type="invalid">{interfaceSourceError}</Form.Control.Feedback>
1123+
)}
11161124
</Form.Group>
11171125
</Col>
11181126
)}

src/components/JsonEditor.tsx

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import React, { useCallback } from 'react';
2+
import MonacoEditor from '@monaco-editor/react';
3+
4+
interface JsonEditorProps {
5+
resource: {};
6+
value: string;
7+
onChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
8+
}
9+
10+
const JsonEditor: React.FC<JsonEditorProps> = React.memo(({ resource, value, onChange }) => {
11+
const handleChange = useCallback(
12+
(newValue: any) => {
13+
const e = { target: { value: newValue } } as React.ChangeEvent<HTMLInputElement>;
14+
onChange(e);
15+
},
16+
[onChange],
17+
);
18+
19+
const handleEditorMount = (editor: any, monaco: any) => {
20+
if (monaco) {
21+
monaco.languages.json.jsonDefaults.setDiagnosticsOptions({
22+
validate: true,
23+
schemas: [
24+
{
25+
fileMatch: ['*'],
26+
schema: resource,
27+
uri: '',
28+
},
29+
],
30+
});
31+
32+
monaco.languages.typescript.typescriptDefaults.setCompilerOptions({ strict: true });
33+
34+
monaco.languages.registerCompletionItemProvider('json', {
35+
provideCompletionItems: (
36+
model: {
37+
getValueInRange: (arg0: {
38+
startLineNumber: any;
39+
startColumn: number;
40+
endLineNumber: any;
41+
endColumn: any;
42+
}) => any;
43+
},
44+
position: { lineNumber: any; column: any },
45+
) => {
46+
const textUntilPosition = model.getValueInRange({
47+
startLineNumber: position.lineNumber,
48+
startColumn: 1,
49+
endLineNumber: position.lineNumber,
50+
endColumn: position.column,
51+
});
52+
53+
if (!/\"type\"\s*:\s*\"?$/.test(textUntilPosition)) {
54+
return { suggestions: [] };
55+
}
56+
57+
return { suggestions: [] };
58+
},
59+
});
60+
}
61+
};
62+
63+
return (
64+
<MonacoEditor
65+
language="json"
66+
value={value}
67+
onChange={handleChange}
68+
options={{
69+
theme: 'light',
70+
quickSuggestions: true,
71+
suggestOnTriggerCharacters: true,
72+
}}
73+
onMount={handleEditorMount}
74+
/>
75+
);
76+
});
77+
78+
export default JsonEditor;

src/components/MonacoContext.tsx

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import React, { createContext, useContext, useEffect, useState } from 'react';
2+
import monaco from '@monaco-editor/react';
3+
4+
interface MonacoContextType {
5+
monaco: typeof monaco | null;
6+
}
7+
8+
const MonacoContext = createContext<MonacoContextType | undefined>(undefined);
9+
10+
export const MonacoProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
11+
const [monacoInstance, setMonacoInstance] = useState<typeof monaco | null>(null);
12+
13+
useEffect(() => {
14+
if (!monacoInstance) {
15+
setMonacoInstance(monaco);
16+
}
17+
}, [monacoInstance]);
18+
19+
return (
20+
<MonacoContext.Provider value={{ monaco: monacoInstance }}>{children}</MonacoContext.Provider>
21+
);
22+
};
23+
24+
export const useMonacoContext = (): MonacoContextType => {
25+
const context = useContext(MonacoContext);
26+
if (!context) {
27+
throw new Error('useMonacoContext must be used within a MonacoProvider');
28+
}
29+
return context;
30+
};

src/components/TriggerDeliveryPolicyEditor.tsx

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@ import { AstarteTriggerDeliveryPolicyDTO } from 'astarte-client/types/dto';
2424
import * as yup from 'yup';
2525
import { AstarteTriggerDeliveryPolicy } from 'astarte-client/models/Policy';
2626
import TriggerDeliveryPolicyHandler from './TriggerDeliveryPolicyHandler';
27+
import { DeliveryPoliciesSchema } from 'schemas/jsonSchemas';
28+
import JsonEditor from './JsonEditor';
2729

2830
const validateName = (name: string) => {
2931
const regex = /^(?!@).{1,128}$/;
@@ -250,17 +252,20 @@ export default ({
250252
<Col md={6}>
251253
<Form.Group controlId="policySource" className="h-100 d-flex flex-column">
252254
<Form.Control
253-
as="textarea"
255+
as="div"
254256
className="flex-grow-1 font-monospace"
255-
value={policySource}
256-
onChange={handlePolicySourceChange}
257257
autoComplete="off"
258258
required
259259
readOnly={isReadOnly}
260260
isValid={isValidPolicySource}
261261
isInvalid={!isValidPolicySource}
262262
spellCheck={false}
263263
/>
264+
<JsonEditor
265+
resource={DeliveryPoliciesSchema}
266+
value={policySource}
267+
onChange={handlePolicySourceChange}
268+
/>
264269
<Form.Control.Feedback type="invalid">{policySourceError}</Form.Control.Feedback>
265270
</Form.Group>
266271
</Col>

src/components/TriggerEditor/index.tsx

Lines changed: 21 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@ import EditAmqpHeaderModal from './EditAmqpHeaderModal';
2929
import EditHttpHeaderModal from './EditHttpHeaderModal';
3030
import DeleteAmqpHeaderModal from './DeleteAmqpHeaderModal';
3131
import DeleteHttpHeaderModal from './DeleteHttpHeaderModal';
32+
import JsonEditor from 'components/JsonEditor';
33+
import { AstarteTriggerSchema } from 'schemas/jsonSchemas';
3234

3335
const defaultTrigger: AstarteTrigger = {
3436
name: '',
@@ -203,22 +205,25 @@ export default ({
203205

204206
const handleFetchInterfacesForTrigger = useCallback(
205207
async (trigger: AstarteTrigger) => {
206-
await handleFetchInterfacesName();
208+
const names = await handleFetchInterfacesName();
207209
const interfaceName = _.get(trigger, 'simpleTriggers[0].interfaceName') as string | undefined;
208210
if (!interfaceName || interfaceName === '*') {
209211
return trigger;
210212
}
211-
const ifaceMajors = await handleFetchInterfaceMajors(interfaceName);
212-
let ifaceMajor: number | undefined = _.get(trigger, 'simpleTriggers[0].interfaceMajor');
213-
if (ifaceMajor == null) {
214-
if (ifaceMajors.length === 0) {
215-
return trigger;
213+
if (interfaceName in names) {
214+
const ifaceMajors = await handleFetchInterfaceMajors(interfaceName);
215+
let ifaceMajor: number | undefined = _.get(trigger, 'simpleTriggers[0].interfaceMajor');
216+
if (ifaceMajor == null) {
217+
if (ifaceMajors.length === 0) {
218+
return trigger;
219+
}
220+
ifaceMajor = Math.max(...ifaceMajors);
221+
_.set(trigger as AstarteTrigger, 'simpleTriggers[0].interfaceMajor', ifaceMajor);
216222
}
217-
ifaceMajor = Math.max(...ifaceMajors);
218-
_.set(trigger as AstarteTrigger, 'simpleTriggers[0].interfaceMajor', ifaceMajor);
223+
224+
const interfaceMajor = ifaceMajor;
225+
await handleFetchInterface({ interfaceName, interfaceMajor });
219226
}
220-
const interfaceMajor = ifaceMajor;
221-
await handleFetchInterface({ interfaceName, interfaceMajor });
222227
return trigger;
223228
},
224229
[handleFetchInterfacesName, handleFetchInterfaceMajors, handleFetchInterface],
@@ -384,6 +389,7 @@ export default ({
384389
const { value } = e.target;
385390
setTriggerSource(value);
386391
let triggerSourceJSON: Record<string, unknown> | null = null;
392+
387393
try {
388394
triggerSourceJSON = JSON.parse(value);
389395
} catch {
@@ -533,15 +539,17 @@ export default ({
533539
<Col md={6}>
534540
<Form.Group controlId="triggerSource" className="h-100 d-flex flex-column">
535541
<Form.Control
536-
as="textarea"
542+
as="div"
537543
className="flex-grow-1 font-monospace"
538544
autoComplete="off"
539545
spellCheck={false}
540546
required
541-
readOnly={isReadOnly}
547+
isInvalid={!!triggerSourceError}
548+
/>
549+
<JsonEditor
550+
resource={AstarteTriggerSchema(interfacesName, realm, policiesName)}
542551
value={triggerSource}
543552
onChange={handleTriggerSourceChange}
544-
isInvalid={!!triggerSourceError}
545553
/>
546554
<Form.Control.Feedback type="invalid">{triggerSourceError}</Form.Control.Feedback>
547555
</Form.Group>

0 commit comments

Comments
 (0)