Skip to content

Commit

Permalink
Implement JSON formatting and syntax highlighting
Browse files Browse the repository at this point in the history
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>
  • Loading branch information
AmerMesanovic committed Jul 11, 2024
1 parent f0cbb12 commit e493080
Show file tree
Hide file tree
Showing 7 changed files with 8,106 additions and 23 deletions.
7,877 changes: 7,877 additions & 0 deletions package-lock.json

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@
"@emotion/react": "^11.11.3",
"@emotion/styled": "^11.11.0",
"@fortawesome/fontawesome-free": "^6.5.1",
"@monaco-editor/react": "^4.6.0",
"@projectstorm/react-canvas-core": "^7.0.2",
"@projectstorm/react-diagrams": "^7.0.3",
"@reduxjs/toolkit": "^2.1.0",
Expand Down
21 changes: 14 additions & 7 deletions src/components/InterfaceEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,8 @@ import _ from 'lodash';

import Icon from './Icon';
import MappingEditor from './MappingEditor';

import JsonEditor from './JsonEditor';
import { AstarteInterfaceSchema } from '../schemas/jsonSchemas';
interface FormControlWarningProps {
message?: string;
}
Expand Down Expand Up @@ -1028,16 +1029,22 @@ export default ({
<Col md={6}>
<Form.Group controlId="interfaceSource" className="h-100 d-flex flex-column">
<Form.Control
as="textarea"
className="flex-grow-1 font-monospace"
value={interfaceSource}
onChange={handleInterfaceSourceChange}
as="div"
autoComplete="off"
required
isValid={isValidInterfaceSource}
isInvalid={!isValidInterfaceSource}
/>
<Form.Control.Feedback type="invalid">{interfaceSourceError}</Form.Control.Feedback>
className="flex-grow-1 font-monospace"
>
<JsonEditor
resource={AstarteInterfaceSchema}
value={interfaceSource}
onChange={handleInterfaceSourceChange}
/>
</Form.Control>
{!isValidInterfaceSource && (
<Form.Control.Feedback type="invalid">{interfaceSourceError}</Form.Control.Feedback>
)}
</Form.Group>
</Col>
)}
Expand Down
59 changes: 59 additions & 0 deletions src/components/JsonEditor.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import React, { useEffect } from 'react';
import MonacoEditor, { useMonaco } from '@monaco-editor/react';

interface JsonEditorProps {
resource: {};
value: string;
onChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
}

const JsonEditor: React.FC<JsonEditorProps> = ({ resource, value, onChange }) => {
const monaco = useMonaco();
useEffect(() => {
if (monaco) {
monaco.languages.json.jsonDefaults.setDiagnosticsOptions({
validate: true,
schemas: [
{
fileMatch: ['*'],
schema: resource,
uri: '',
},
],
});

monaco.languages.registerCompletionItemProvider('json', {
provideCompletionItems: (model, position) => {
const textUntilPosition = model.getValueInRange({
startLineNumber: position.lineNumber,
startColumn: 1,
endLineNumber: position.lineNumber,
endColumn: position.column,
});

if (!/\"type\"\s*:\s*\"?$/.test(textUntilPosition)) {
return { suggestions: [] };
}
},
});
}
}, [monaco]);

return (
<MonacoEditor
language="json"
value={value}
onChange={(newValue) => {
const e = { target: { value: newValue } } as React.ChangeEvent<HTMLInputElement>;
onChange(e);
}}
options={{
theme: 'light',
quickSuggestions: true,
suggestOnTriggerCharacters: true,
}}
/>
);
};

export default JsonEditor;
11 changes: 8 additions & 3 deletions src/components/TriggerDeliveryPolicyEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ import { AstarteTriggerDeliveryPolicyDTO } from 'astarte-client/types/dto';
import * as yup from 'yup';
import { AstarteTriggerDeliveryPolicy } from 'astarte-client/models/Policy';
import TriggerDeliveryPolicyHandler from './TriggerDeliveryPolicyHandler';
import { DeliveryPoliciesSchema } from 'schemas/jsonSchemas';
import JsonEditor from './JsonEditor';

const validateName = (name: string) => {
const regex = /^(?!@).{1,128}$/;
Expand Down Expand Up @@ -250,17 +252,20 @@ export default ({
<Col md={6}>
<Form.Group controlId="policySource" className="h-100 d-flex flex-column">
<Form.Control
as="textarea"
as="div"
className="flex-grow-1 font-monospace"
value={policySource}
onChange={handlePolicySourceChange}
autoComplete="off"
required
readOnly={isReadOnly}
isValid={isValidPolicySource}
isInvalid={!isValidPolicySource}
spellCheck={false}
/>
<JsonEditor
resource={DeliveryPoliciesSchema}
value={policySource}
onChange={handlePolicySourceChange}
/>
<Form.Control.Feedback type="invalid">{policySourceError}</Form.Control.Feedback>
</Form.Group>
</Col>
Expand Down
34 changes: 21 additions & 13 deletions src/components/TriggerEditor/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ import EditAmqpHeaderModal from './EditAmqpHeaderModal';
import EditHttpHeaderModal from './EditHttpHeaderModal';
import DeleteAmqpHeaderModal from './DeleteAmqpHeaderModal';
import DeleteHttpHeaderModal from './DeleteHttpHeaderModal';
import JsonEditor from 'components/JsonEditor';
import { AstarteTriggerSchema } from 'schemas/jsonSchemas';

const defaultTrigger: AstarteTrigger = {
name: '',
Expand Down Expand Up @@ -200,22 +202,25 @@ export default ({

const handleFetchInterfacesForTrigger = useCallback(
async (trigger: AstarteTrigger) => {
await handleFetchInterfacesName();
const names = await handleFetchInterfacesName();
const interfaceName = _.get(trigger, 'simpleTriggers[0].interfaceName') as string | undefined;
if (!interfaceName || interfaceName === '*') {
return trigger;
}
const ifaceMajors = await handleFetchInterfaceMajors(interfaceName);
let ifaceMajor: number | undefined = _.get(trigger, 'simpleTriggers[0].interfaceMajor');
if (ifaceMajor == null) {
if (ifaceMajors.length === 0) {
return trigger;
if (interfaceName in names) {
const ifaceMajors = await handleFetchInterfaceMajors(interfaceName);
let ifaceMajor: number | undefined = _.get(trigger, 'simpleTriggers[0].interfaceMajor');
if (ifaceMajor == null) {
if (ifaceMajors.length === 0) {
return trigger;
}
ifaceMajor = Math.max(...ifaceMajors);
_.set(trigger as AstarteTrigger, 'simpleTriggers[0].interfaceMajor', ifaceMajor);
}
ifaceMajor = Math.max(...ifaceMajors);
_.set(trigger as AstarteTrigger, 'simpleTriggers[0].interfaceMajor', ifaceMajor);

const interfaceMajor = ifaceMajor;
await handleFetchInterface({ interfaceName, interfaceMajor });
}
const interfaceMajor = ifaceMajor;
await handleFetchInterface({ interfaceName, interfaceMajor });
return trigger;
},
[handleFetchInterfacesName, handleFetchInterfaceMajors, handleFetchInterface],
Expand Down Expand Up @@ -381,6 +386,7 @@ export default ({
const { value } = e.target;
setTriggerSource(value);
let triggerSourceJSON: Record<string, unknown> | null = null;

try {
triggerSourceJSON = JSON.parse(value);
} catch {
Expand Down Expand Up @@ -530,15 +536,17 @@ export default ({
<Col md={6}>
<Form.Group controlId="triggerSource" className="h-100 d-flex flex-column">
<Form.Control
as="textarea"
as="div"
className="flex-grow-1 font-monospace"
autoComplete="off"
spellCheck={false}
required
readOnly={isReadOnly}
isInvalid={!!triggerSourceError}
/>
<JsonEditor
resource={AstarteTriggerSchema(interfacesName, realm, policiesName)}
value={triggerSource}
onChange={handleTriggerSourceChange}
isInvalid={!!triggerSourceError}
/>
<Form.Control.Feedback type="invalid">{triggerSourceError}</Form.Control.Feedback>
</Form.Group>
Expand Down
126 changes: 126 additions & 0 deletions src/schemas/jsonSchemas.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
export const AstarteInterfaceSchema = {
type: 'object',
properties: {
interface_name: { type: 'string' },
version_major: { type: 'number' },
version_minor: { type: 'number' },
type: {
type: 'string',
enum: ['properties', 'datastream'],
},
ownership: {
type: 'string',
enum: ['device', 'server'],
},
aggregation: {
type: 'string',
enum: ['individual', 'object'],
},
mappings: { type: 'array' },
},
required: ['interface_name', 'version_major', 'version_minor', 'type', 'ownership'],
};

export const AstarteTriggerSchema = (
interfacesName: string[],
realm?: string | null,
policiesName?: string | string[],
) => ({
type: 'object',
properties: {
name: { type: 'string', minLength: 1 },
action: {
type: 'object',
oneOf: [
{
properties: {
amqp_exchange: {
type: 'string',
pattern: `^astarte_events_${realm}_.+$`,
examples: [`astarte_events_${realm}_`],
},
amqp_message_expiration_ms: { type: 'number', exclusiveMinimum: 0 },
amqp_message_persistent: { type: 'boolean' },
amqp_routing_key: { type: 'string' },
},
required: ['amqp_exchange', 'amqp_message_expiration_ms', 'amqp_message_persistent'],
},
{
properties: {
http_url: { type: 'string', minLength: 8 },
http_method: { type: 'string', enum: ['get', 'post', 'put', 'delete'] },
ignore_ssl_errors: { type: 'boolean' },
},
required: ['http_url', 'http_method'],
},
],
},
simple_triggers: {
type: 'array',
items: {
type: 'object',
properties: {
type: { type: 'string', enum: ['data_trigger', 'device_trigger'] },
on: { type: 'string' },
interface_name: { type: 'string', enum: ['*', ...interfacesName] },
match_path: { type: 'string' },
value_match_operator: { type: 'string', enum: ['*'] },
},
required: ['type'],
allOf: [
{
if: {
properties: { type: { const: 'data_trigger' } },
},
then: {
properties: {
on: { type: 'string', enum: ['incoming_data', 'value_stored'] },
interface_name: { type: 'string', enum: ['*', ...interfacesName] },
match_path: { type: 'string' },
value_match_operator: { type: 'string', enum: ['*'] },
},
required: ['on', 'interface_name', 'match_path', 'value_match_operator'],
},
else: {
properties: {
on: {
type: 'string',
enum: [
'device_connected',
'device_disconnected',
'device_error',
'device_empty_cache_received',
],
},
},
required: ['on'],
},
},
],
},
},
policy: { type: 'string', enum: policiesName },
},
required: ['name', 'action', 'simple_triggers'],
});

export const DeliveryPoliciesSchema = {
type: 'object',
properties: {
name: { type: 'string', minLength: 1 },
error_handlers: {
type: 'array',
items: {
type: 'object',
properties: {
on: { type: 'string', minLength: 1, enum: ['client_error', 'server_error', 'any_error'] },
strategy: { type: 'string', minLength: 1, enum: ['discard', 'retry'] },
},
required: ['on', 'strategy'],
},
},
maximum_capacity: { type: 'number', exclusiveMinimum: 0 },
event_ttl: { type: 'number' },
},
required: ['name', 'error_handlers', 'maximum_capacity'],
};

0 comments on commit e493080

Please sign in to comment.