From c293c7dc95f8170b1e480860f2c3238a74127b65 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20Urba=C5=84czyk?= Date: Thu, 14 Oct 2021 11:03:11 +0200 Subject: [PATCH] feat: add to editor an error markers (#116) --- package.json | 5 + src/components/Navigation.tsx | 4 +- src/components/Template/HTMLWrapper.tsx | 5 +- src/components/Template/Template.tsx | 4 +- src/components/Terminal/ProblemsTab.tsx | 42 +++-- src/components/Terminal/TerminalInfo.tsx | 3 +- src/services/editor.service.ts | 67 +++++++ src/services/navigation.service.ts | 8 +- src/services/specification.service.ts | 122 ++++++------ src/services/tests/editor.service.test.ts | 219 ++++++++++++++++++++++ src/state/editor.ts | 2 + src/state/parser.ts | 2 + 12 files changed, 402 insertions(+), 81 deletions(-) create mode 100644 src/services/tests/editor.service.test.ts diff --git a/package.json b/package.json index c5bf4bbbd..561365788 100644 --- a/package.json +++ b/package.json @@ -66,5 +66,10 @@ "eslint-plugin-sonarjs": "^0.10.0", "react-scripts": "4.0.3", "typescript": "^4.4.3" + }, + "jest": { + "transformIgnorePatterns": [ + "node_modules\/(?!(monaco-editor)\/)" + ] } } diff --git a/src/components/Navigation.tsx b/src/components/Navigation.tsx index 02fbebb8a..632565445 100644 --- a/src/components/Navigation.tsx +++ b/src/components/Navigation.tsx @@ -267,10 +267,10 @@ export const Navigation: React.FunctionComponent = () => { const spec = parserState.parsedSpec.get(); useEffect(() => { - // remove `#` char const fn = () => { // remove `#` char - setHash(window.location.hash.substring(1)); + const h = window.location.hash.startsWith('#') ? window.location.hash.substring(1) : window.location.hash; + setHash(h); }; fn(); window.addEventListener('hashchange', fn); diff --git a/src/components/Template/HTMLWrapper.tsx b/src/components/Template/HTMLWrapper.tsx index c49e2cccf..3e855405f 100644 --- a/src/components/Template/HTMLWrapper.tsx +++ b/src/components/Template/HTMLWrapper.tsx @@ -11,6 +11,7 @@ export const HTMLWrapper: React.FunctionComponent = () => { const parserState = state.useParserState(); const editorState = state.useEditorState(); + const documentValid = parserState.valid.get(); const editorLoaded = editorState.editorLoaded.get(); // using "json()" for removing proxy from value @@ -33,10 +34,10 @@ export const HTMLWrapper: React.FunctionComponent = () => { ); } - if (!parsedSpec) { + if (!documentValid) { return (
- Empty or invalid document. Please fix errors/define AsyncAPI document. +

Empty or invalid document. Please fix errors/define AsyncAPI document.

); } diff --git a/src/components/Template/Template.tsx b/src/components/Template/Template.tsx index 0b10a1dca..564734b73 100644 --- a/src/components/Template/Template.tsx +++ b/src/components/Template/Template.tsx @@ -5,5 +5,7 @@ import { HTMLWrapper } from './HTMLWrapper'; interface TemplateProps {} export const Template: React.FunctionComponent = () => { - return ; + return ( + + ); }; diff --git a/src/components/Terminal/ProblemsTab.tsx b/src/components/Terminal/ProblemsTab.tsx index 3eeee0951..8ac19b851 100644 --- a/src/components/Terminal/ProblemsTab.tsx +++ b/src/components/Terminal/ProblemsTab.tsx @@ -30,26 +30,34 @@ export const ProblemsTabContent: React.FunctionComponent = () - - + + + - {errors.map((err: any, id) => ( - - - - - ))} + {errors.map((err: any, id) => { + const { title, detail, location } = err; + let renderedLine = err.location?.startLine; + renderedLine = renderedLine && err.location?.startColumn ? `${renderedLine}:${err.location?.startColumn}` : renderedLine; + return ( + + + + + + ); + })}
LineDescriptionLineTitleDetails
- NavigationService.scrollToEditorLine( - err.location?.startLine || 0, - ) - } - > - {err.location?.startLine || '-'} - {err.title}
+ NavigationService.scrollToEditorLine( + location?.startLine || 0, + location?.startColumn, + ) + } + > + {renderedLine || '-'} + {title}{detail || '-'}
diff --git a/src/components/Terminal/TerminalInfo.tsx b/src/components/Terminal/TerminalInfo.tsx index be48d794f..774eb9136 100644 --- a/src/components/Terminal/TerminalInfo.tsx +++ b/src/components/Terminal/TerminalInfo.tsx @@ -11,6 +11,7 @@ export const TerminalInfo: React.FunctionComponent = () => { const actualVersion = parserState.parsedSpec.get()?.version() || '2.0.0'; const latestVersion = SpecificationService.getLastVersion(); + const documentValid = parserState.valid.get(); const errors = parserState.errors.get(); function onNonLatestClick(e: React.MouseEvent) { @@ -61,7 +62,7 @@ export const TerminalInfo: React.FunctionComponent = () => { Valid )} - {actualVersion !== latestVersion && ( + {actualVersion !== latestVersion && documentValid === true && (
diff --git a/src/services/editor.service.ts b/src/services/editor.service.ts index 54a4d644a..bae015fd8 100644 --- a/src/services/editor.service.ts +++ b/src/services/editor.service.ts @@ -143,6 +143,73 @@ export class EditorService { } } + static applyErrorMarkers(errors: any[] = []) { + const editor = this.getInstance(); + const Monaco = window.Monaco; + + if (!editor || !Monaco) { + return; + } + + const model = editor.getModel(); + if (!model) { + return; + } + + const oldDecorations = state.editor.decorations.get(); + editor.deltaDecorations(oldDecorations, []); + Monaco.editor.setModelMarkers(model, 'asyncapi', []); + if (errors.length === 0) { + return; + } + + const { markers, decorations } = this.createErrorMarkers(errors, model, Monaco); + Monaco.editor.setModelMarkers(model, 'asyncapi', markers); + editor.deltaDecorations(oldDecorations, decorations); + } + + static createErrorMarkers(errors: any[] = [], model: monacoAPI.editor.ITextModel, Monaco: typeof monacoAPI) { + const newDecorations: monacoAPI.editor.IModelDecoration[] = []; + const newMarkers: monacoAPI.editor.IMarkerData[] = []; + errors.forEach(err => { + const { title, detail } = err; + let location = err.location; + + if (!location || location.jsonPointer === '/') { + const fullRange = model.getFullModelRange(); + location = {}; + location.startLine = fullRange.startLineNumber; + location.startColumn = fullRange.startColumn; + location.endLine = fullRange.endLineNumber; + location.endColumn = fullRange.endColumn; + } + const { startLine, startColumn, endLine, endColumn } = location; + + const detailContent = detail ? `\n\n${detail}` : ''; + newMarkers.push({ + startLineNumber: startLine, + startColumn, + endLineNumber: typeof endLine === 'number' ? endLine : startLine, + endColumn: typeof endColumn === 'number' ? endColumn : startColumn, + severity: monacoAPI.MarkerSeverity.Error, + message: `${title}${detailContent}`, + }); + newDecorations.push({ + id: 'asyncapi', + ownerId: 0, + range: new Monaco.Range( + startLine, + startColumn, + typeof endLine === 'number' ? endLine : startLine, + typeof endColumn === 'number' ? endColumn : startColumn + ), + options: { inlineClassName: 'bg-red-500-20' }, + }); + }); + + return { decorations: newDecorations, markers: newMarkers }; + } + private static fileName = 'asyncapi'; private static downloadFile(content: string, fileName: string) { diff --git a/src/services/navigation.service.ts b/src/services/navigation.service.ts index 11efa17ea..28d9d215a 100644 --- a/src/services/navigation.service.ts +++ b/src/services/navigation.service.ts @@ -40,6 +40,10 @@ export class NavigationService { hash = hash || window.location.hash.substring(1); try { const escapedHash = CSS.escape(hash); + if (!escapedHash || escapedHash === '#') { + return; + } + const items = document.querySelectorAll( escapedHash.startsWith('#') ? escapedHash : `#${escapedHash}`, ); @@ -53,11 +57,11 @@ export class NavigationService { } } - static scrollToEditorLine(startLine: number) { + static scrollToEditorLine(startLine: number, columnLine = 1) { try { const editor = window.Editor; editor && editor.revealLineInCenter(startLine); - editor && editor.setPosition({ column: 1, lineNumber: startLine }); + editor && editor.setPosition({ lineNumber: startLine, column: columnLine }); } catch (err) { console.error(err); } diff --git a/src/services/specification.service.ts b/src/services/specification.service.ts index 7bb6620ff..f4ccdc05d 100644 --- a/src/services/specification.service.ts +++ b/src/services/specification.service.ts @@ -4,6 +4,7 @@ import { parse, AsyncAPIDocument } from '@asyncapi/parser'; // @ts-ignore import specs from '@asyncapi/specs'; +import { EditorService } from './editor.service'; import { FormatService } from './format.service'; import { MonacoService } from './monaco.service'; @@ -14,9 +15,12 @@ export class SpecificationService { const parserState = state.parser; return parse(rawSpec) .then(asyncApiDoc => { - parserState.parsedSpec.set(asyncApiDoc); - parserState.valid.set(true); - parserState.errors.set([]); + parserState.set({ + parsedSpec: asyncApiDoc, + lastParsedSpec: asyncApiDoc, + valid: true, + errors: [], + }); MonacoService.updateLanguageConfig(asyncApiDoc); if (this.shouldInformAboutLatestVersion(asyncApiDoc.version())) { @@ -27,13 +31,19 @@ export class SpecificationService { }); } + EditorService.applyErrorMarkers([]); return asyncApiDoc; }) .catch(err => { const errors = this.filterErrors(err, rawSpec); - parserState.parsedSpec.set(null); - parserState.valid.set(false); - parserState.errors.set(errors); + + parserState.set({ + parsedSpec: null, + lastParsedSpec: parserState.parsedSpec.get() || parserState.lastParsedSpec.get(), + valid: false, + errors, + }); + EditorService.applyErrorMarkers(errors); }); } @@ -61,56 +71,6 @@ export class SpecificationService { return Object.keys(specs).pop() as string; } - static errorHasLocation(err: any) { - return ( - this.isValidationError(err) || - this.isJsonError(err) || - this.isYamlError(err) || - this.isDereferenceError(err) || - this.isUnsupportedVersionError(err) - ); - } - - static isValidationError(err: any) { - return ( - err && - err.type === 'https://github.com/asyncapi/parser-js/validation-errors' - ); - } - - static isJsonError(err: any) { - return ( - err && err.type === 'https://github.com/asyncapi/parser-js/invalid-json' - ); - } - - static isYamlError(err: any) { - return ( - err && err.type === 'https://github.com/asyncapi/parser-js/invalid-yaml' - ); - } - - static isUnsupportedVersionError(err: any) { - return ( - err && - err.type === 'https://github.com/asyncapi/parser-js/unsupported-version' - ); - } - - static isDereferenceError(err: any) { - return ( - err && - err.type === 'https://github.com/asyncapi/parser-js/dereference-error' - ); - } - - static isNotSupportedVersion(rawSpec: string): boolean { - if (this.notSupportedVersions.test(rawSpec.trim())) { - return true; - } - return false; - } - static shouldInformAboutLatestVersion( version: string, ): boolean { @@ -134,6 +94,16 @@ export class SpecificationService { return false; } + static errorHasLocation(err: any) { + return ( + this.isValidationError(err) || + this.isJsonError(err) || + this.isYamlError(err) || + this.isDereferenceError(err) || + this.isUnsupportedVersionError(err) + ); + } + private static notSupportedVersions = /('|"|)asyncapi('|"|): ('|"|)(1.0.0|1.1.0|1.2.0|2.0.0-rc1|2.0.0-rc2)('|"|)/; private static filterErrors(err: any, rawSpec: string) { @@ -171,4 +141,44 @@ export class SpecificationService { } return errors; } + + private static isValidationError(err: any) { + return ( + err && + err.type === 'https://github.com/asyncapi/parser-js/validation-errors' + ); + } + + private static isJsonError(err: any) { + return ( + err && err.type === 'https://github.com/asyncapi/parser-js/invalid-json' + ); + } + + private static isYamlError(err: any) { + return ( + err && err.type === 'https://github.com/asyncapi/parser-js/invalid-yaml' + ); + } + + private static isUnsupportedVersionError(err: any) { + return ( + err && + err.type === 'https://github.com/asyncapi/parser-js/unsupported-version' + ); + } + + private static isDereferenceError(err: any) { + return ( + err && + err.type === 'https://github.com/asyncapi/parser-js/dereference-error' + ); + } + + private static isNotSupportedVersion(rawSpec: string): boolean { + if (this.notSupportedVersions.test(rawSpec.trim())) { + return true; + } + return false; + } } diff --git a/src/services/tests/editor.service.test.ts b/src/services/tests/editor.service.test.ts new file mode 100644 index 000000000..a498436e8 --- /dev/null +++ b/src/services/tests/editor.service.test.ts @@ -0,0 +1,219 @@ +import * as monacoAPI from 'monaco-editor/esm/vs/editor/editor.api'; +import { EditorService } from '../editor.service'; + +function createMonacoModelMock(): monacoAPI.editor.ITextModel { + return { + getFullModelRange() { + return { + endColumn: 5, + endLineNumber: 3, + startColumn: 5, + startLineNumber: 3, + }; + } + } as monacoAPI.editor.ITextModel; +} + +describe('EditorService', () => { + describe('.createErrorMarkers', () => { + test('should create markers and decorators with errors', () => { + const errors: any[] = [ + { + title: 'some error 1', + location: { + startLine: 3, + startColumn: 5, + endLine: 10, + endColumn: 15, + }, + detail: 'some details', + }, + { + title: 'some error 2', + location: { + startLine: 1, + startColumn: 2, + endLine: 2, + endColumn: 3, + }, + detail: 'some details', + } + ]; + + const { markers, decorations } = EditorService.createErrorMarkers(errors, null as any, monacoAPI); + expect(markers.length).toEqual(2); + expect(decorations.length).toEqual(2); + + // markers + expect(markers[0]).toEqual({ + endColumn: 15, + endLineNumber: 10, + startColumn: 5, + startLineNumber: 3, + message: 'some error 1\n\nsome details', + severity: monacoAPI.MarkerSeverity.Error + }); + expect(markers[1]).toEqual({ + endColumn: 3, + endLineNumber: 2, + startColumn: 2, + startLineNumber: 1, + message: 'some error 2\n\nsome details', + severity: monacoAPI.MarkerSeverity.Error + }); + // decorations + expect(decorations[0]).toEqual({ + id: 'asyncapi', + options: { + inlineClassName: 'bg-red-500-20', + }, + ownerId: 0, + range: { + endColumn: 15, + endLineNumber: 10, + startColumn: 5, + startLineNumber: 3, + }, + }); + expect(decorations[1]).toEqual({ + id: 'asyncapi', + options: { + inlineClassName: 'bg-red-500-20', + }, + ownerId: 0, + range: { + endColumn: 3, + endLineNumber: 2, + startColumn: 2, + startLineNumber: 1, + }, + }); + }); + + test('should not create markers and decorators without errors', () => { + const errors: any[] = []; + + const { markers, decorations } = EditorService.createErrorMarkers(errors, null as any, monacoAPI); + expect(markers.length).toEqual(0); + expect(decorations.length).toEqual(0); + }); + + test('should handle siturion without endLine and endColumn', () => { + const errors: any[] = [ + { + title: 'some error 1', + location: { + startLine: 3, + startColumn: 5, + }, + detail: 'some details', + }, + ]; + + const { markers, decorations } = EditorService.createErrorMarkers(errors, null as any, monacoAPI); + expect(markers.length).toEqual(1); + expect(decorations.length).toEqual(1); + + // markers + expect(markers[0]).toEqual({ + endColumn: 5, + endLineNumber: 3, + startColumn: 5, + startLineNumber: 3, + message: 'some error 1\n\nsome details', + severity: monacoAPI.MarkerSeverity.Error + }); + // decorators + expect(decorations[0]).toEqual({ + id: 'asyncapi', + options: { + inlineClassName: 'bg-red-500-20', + }, + ownerId: 0, + range: { + endColumn: 5, + endLineNumber: 3, + startColumn: 5, + startLineNumber: 3, + }, + }); + }); + + test('should handle situation with non location', () => { + const errors: any[] = [ + { + title: 'some error 1', + detail: 'some details', + } + ]; + + const { markers, decorations } = EditorService.createErrorMarkers(errors, createMonacoModelMock(), monacoAPI); + expect(markers.length).toEqual(1); + expect(decorations.length).toEqual(1); + + // markers + expect(markers[0]).toEqual({ + endColumn: 5, + endLineNumber: 3, + startColumn: 5, + startLineNumber: 3, + message: 'some error 1\n\nsome details', + severity: monacoAPI.MarkerSeverity.Error + }); + // decorators + expect(decorations[0]).toEqual({ + id: 'asyncapi', + options: { + inlineClassName: 'bg-red-500-20', + }, + ownerId: 0, + range: { + endColumn: 5, + endLineNumber: 3, + startColumn: 5, + startLineNumber: 3, + }, + }); + }); + + test('should handle situation with root jsonPointer in location', () => { + const errors: any[] = [ + { + title: 'some error 1', + location: { + jsonPointer: '/' + }, + detail: 'some details', + } + ]; + + const { markers, decorations } = EditorService.createErrorMarkers(errors, createMonacoModelMock(), monacoAPI); + expect(markers.length).toEqual(1); + expect(decorations.length).toEqual(1); + + // markers + expect(markers[0]).toEqual({ + endColumn: 5, + endLineNumber: 3, + startColumn: 5, + startLineNumber: 3, + message: 'some error 1\n\nsome details', + severity: monacoAPI.MarkerSeverity.Error + }); + // decorators + expect(decorations[0]).toEqual({ + id: 'asyncapi', + options: { + inlineClassName: 'bg-red-500-20', + }, + ownerId: 0, + range: { + endColumn: 5, + endLineNumber: 3, + startColumn: 5, + startLineNumber: 3, + }, + }); + }); + }); +}); diff --git a/src/state/editor.ts b/src/state/editor.ts index 77a168184..3936f2ef4 100644 --- a/src/state/editor.ts +++ b/src/state/editor.ts @@ -85,6 +85,7 @@ export interface EditorState { editorValue: string; monacoLoaded: boolean; editorLoaded: boolean; + decorations: Array; } export const editorState = createState({ @@ -94,6 +95,7 @@ export const editorState = createState({ editorValue: schema, monacoLoaded: false, editorLoaded: false, + decorations: [], }); export function useEditorState() { diff --git a/src/state/parser.ts b/src/state/parser.ts index c1fdbb80a..aadc69e24 100644 --- a/src/state/parser.ts +++ b/src/state/parser.ts @@ -3,12 +3,14 @@ import { createState, useState } from '@hookstate/core'; export interface ParserState { parsedSpec: AsyncAPIDocument | null; + lastParsedSpec: AsyncAPIDocument | null; valid: boolean; errors: any[]; } export const parserState = createState({ parsedSpec: null, + lastParsedSpec: null, valid: false, errors: [], });