diff --git a/web/package-lock.json b/web/package-lock.json index 7859086c4..0ae2ade85 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -56,6 +56,7 @@ "jest": "^29.7.0", "jest-environment-jsdom": "^29.7.0", "js-cookie": "^3.0.5", + "js-yaml": "^4.1.0", "jwt-decode": "^4.0.0", "lodash": "^4.17.21", "monaco-sql-languages": "^0.13.1", @@ -101,6 +102,7 @@ "@testing-library/react": "^16.2.0", "@types/canvas-confetti": "^1.9.0", "@types/js-cookie": "^3.0.6", + "@types/js-yaml": "^4.0.9", "@types/lodash": "^4.17.16", "@types/node": "^20", "@types/papaparse": "^5.3.15", @@ -7329,6 +7331,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/js-yaml": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/js-yaml/-/js-yaml-4.0.9.tgz", + "integrity": "sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/jsdom": { "version": "20.0.1", "resolved": "https://registry.npmjs.org/@types/jsdom/-/jsdom-20.0.1.tgz", @@ -8375,7 +8384,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true, "license": "Python-2.0" }, "node_modules/aria-hidden": { @@ -14910,7 +14918,6 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", - "dev": true, "license": "MIT", "dependencies": { "argparse": "^2.0.1" diff --git a/web/package.json b/web/package.json index 4c0910467..f6ea505fe 100644 --- a/web/package.json +++ b/web/package.json @@ -64,6 +64,7 @@ "jest": "^29.7.0", "jest-environment-jsdom": "^29.7.0", "js-cookie": "^3.0.5", + "js-yaml": "^4.1.0", "jwt-decode": "^4.0.0", "lodash": "^4.17.21", "monaco-sql-languages": "^0.13.1", @@ -109,6 +110,7 @@ "@testing-library/react": "^16.2.0", "@types/canvas-confetti": "^1.9.0", "@types/js-cookie": "^3.0.6", + "@types/js-yaml": "^4.0.9", "@types/lodash": "^4.17.16", "@types/node": "^20", "@types/papaparse": "^5.3.15", diff --git a/web/src/lib/metrics/files/MyYamlEditor.stories.tsx b/web/src/lib/metrics/files/MyYamlEditor.stories.tsx new file mode 100644 index 000000000..07dc07fd8 --- /dev/null +++ b/web/src/lib/metrics/files/MyYamlEditor.stories.tsx @@ -0,0 +1,25 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { MyYamlEditor } from './validateMetricYaml'; + +const meta: Meta = { + title: 'Metrics/Files/MyYamlEditor', + component: MyYamlEditor, + tags: ['autodocs'], + parameters: { + layout: 'centered' + }, + decorators: [ + (Story) => ( +
+ +
+ ) + ] +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: {} +}; diff --git a/web/src/lib/metrics/files/validateMetricYaml.test.ts b/web/src/lib/metrics/files/validateMetricYaml.test.ts new file mode 100644 index 000000000..68e24dc09 --- /dev/null +++ b/web/src/lib/metrics/files/validateMetricYaml.test.ts @@ -0,0 +1,72 @@ +import { validateMetricYaml } from './validateMetricYaml'; +import * as yaml from 'js-yaml'; + +// Create a minimal mock for monaco +const mockMonaco = { + MarkerSeverity: { + Error: 8, // These values match monaco-editor's real values + Warning: 4, + Info: 2, + Hint: 1 + } +}; + +describe('validateMetricYaml', () => { + it('should validate a correctly formatted YAML', () => { + // Arrange + const validYaml = `Person: "John Doe" +Place: "Wonderland" +Age: 30 +Siblings: + Jane: 25 + Jim: 28`; + + // Act + const result = validateMetricYaml(validYaml, mockMonaco as any); + + // Assert + expect(result).toEqual([]); + }); + + it('should detect missing required keys', () => { + // Arrange + const invalidYaml = `Person: "John Doe" +Place: "Wonderland" +Age: 30 +`; // Missing Siblings + + // Act + const result = validateMetricYaml(invalidYaml, mockMonaco as any); + + // Assert + expect(result.length).toBeGreaterThan(0); + expect( + result.some( + (marker) => + marker.message.includes('Missing required key "Siblings"') && + marker.severity === mockMonaco.MarkerSeverity.Error + ) + ).toBe(true); + }); + + it('should detect invalid types for fields', () => { + // Arrange + const invalidYaml = `Person: "John Doe" +Place: "Wonderland" +Age: "thirty" # Should be a number +Siblings: + Jane: 25 + Jim: 28`; + + // Act + const result = validateMetricYaml(invalidYaml, mockMonaco as any); + + // Assert + expect(result.length).toBeGreaterThan(0); + const ageErrorMarker = result.find((marker) => + marker.message.includes('The "Age" field must be a number') + ); + expect(ageErrorMarker).toBeDefined(); + expect(ageErrorMarker?.severity).toBe(mockMonaco.MarkerSeverity.Error); + }); +}); diff --git a/web/src/lib/metrics/files/validateMetricYaml.tsx b/web/src/lib/metrics/files/validateMetricYaml.tsx new file mode 100644 index 000000000..1cc758331 --- /dev/null +++ b/web/src/lib/metrics/files/validateMetricYaml.tsx @@ -0,0 +1,180 @@ +import React, { useRef } from 'react'; +import * as yaml from 'js-yaml'; +import * as monaco from 'monaco-editor'; +import { type editor } from 'monaco-editor/esm/vs/editor/editor.api'; +import { useEffect } from 'react'; +import MonacoEditor, { OnMount } from '@monaco-editor/react'; + +type IMarkerData = editor.IMarkerData; + +// Initial YAML content for testing +const initialValue = `Person: "John Doe" +Place: "Wonderland" +Age: 30 +Siblings: + Jane: 25 + Jim: 28`; + +// Linting function that validates YAML content +export const validateMetricYaml = (content: string, monaco: any): IMarkerData[] => { + const markers: IMarkerData[] = []; + let parsed: any; + + // Parse YAML content + try { + parsed = yaml.load(content); + } catch (error: any) { + markers.push({ + severity: monaco.MarkerSeverity.Error, + message: 'Invalid YAML 🤣: ' + error.message, + startLineNumber: 1, + startColumn: 1, + endLineNumber: 1, + endColumn: 1 + }); + return markers; + } + + // Ensure the parsed result is an object + if (typeof parsed !== 'object' || parsed === null) { + markers.push({ + severity: monaco.MarkerSeverity.Error, + message: 'YAML content should be an object with properties.', + startLineNumber: 1, + startColumn: 1, + endLineNumber: 1, + endColumn: 1 + }); + return markers; + } + + // Allowed keys + const allowedKeys = ['Person', 'Place', 'Age', 'Siblings']; + const keys = Object.keys(parsed); + + // Check for any unexpected keys + keys.forEach((key) => { + if (!allowedKeys.includes(key)) { + markers.push({ + severity: monaco.MarkerSeverity.Error, + message: `Unexpected key "${key}". Only ${allowedKeys.join(', ')} are allowed.`, + startLineNumber: 1, + startColumn: 1, + endLineNumber: 1, + endColumn: 1 + }); + } + }); + + // Check for missing keys + allowedKeys.forEach((key) => { + if (!(key in parsed)) { + markers.push({ + severity: monaco.MarkerSeverity.Error, + message: `Missing required key "${key}".`, + startLineNumber: 1, + startColumn: 1, + endLineNumber: 1, + endColumn: 1 + }); + } + }); + + // Validate types for each field + if ('Person' in parsed && typeof parsed.Person !== 'string') { + markers.push({ + severity: monaco.MarkerSeverity.Error, + message: 'The "Person" field must be a string.', + startLineNumber: 1, + startColumn: 1, + endLineNumber: 1, + endColumn: 1 + }); + } + + if ('Place' in parsed && typeof parsed.Place !== 'string') { + markers.push({ + severity: monaco.MarkerSeverity.Error, + message: 'The "Place" field must be a string.', + startLineNumber: 1, + startColumn: 1, + endLineNumber: 1, + endColumn: 1 + }); + } + + if ('Age' in parsed && typeof parsed.Age !== 'number') { + markers.push({ + severity: monaco.MarkerSeverity.Error, + message: 'The "Age" field must be a number.', + startLineNumber: 1, + startColumn: 1, + endLineNumber: 1, + endColumn: 1 + }); + } + + if ('Siblings' in parsed) { + if ( + typeof parsed.Siblings !== 'object' || + Array.isArray(parsed.Siblings) || + parsed.Siblings === null + ) { + markers.push({ + severity: monaco.MarkerSeverity.Error, + message: 'The "Siblings" field must be an object with sibling name-age pairs.', + startLineNumber: 1, + startColumn: 1, + endLineNumber: 1, + endColumn: 1 + }); + } else { + // Validate that each sibling’s age is a number + Object.entries(parsed.Siblings).forEach(([siblingName, siblingAge]) => { + if (typeof siblingAge !== 'number') { + markers.push({ + severity: monaco.MarkerSeverity.Error, + message: `The age for sibling "${siblingName}" must be a number.`, + startLineNumber: 1, + startColumn: 1, + endLineNumber: 1, + endColumn: 1 + }); + } + }); + } + } + + return markers; +}; + +export const MyYamlEditor: React.FC = () => { + const editorRef = useRef(null); + + // Called once the Monaco editor is mounted + const editorDidMount = (editor: any, monacoInstance: typeof import('monaco-editor')) => { + editorRef.current = editor; + + // Lint the document on every change + editor.onDidChangeModelContent(() => { + const value = editor.getValue(); + const markers = validateMetricYaml(value, monacoInstance); + monacoInstance.editor.setModelMarkers(editor.getModel(), 'yaml', markers); + }); + }; + + return ( + + ); +};