validation yaml

This commit is contained in:
Nate Kelley 2025-04-04 11:54:41 -06:00
parent 32d43db1ea
commit 675379c00b
No known key found for this signature in database
GPG Key ID: FD90372AB8D98B4F
5 changed files with 288 additions and 2 deletions

11
web/package-lock.json generated
View File

@ -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"

View File

@ -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",

View File

@ -0,0 +1,25 @@
import type { Meta, StoryObj } from '@storybook/react';
import { MyYamlEditor } from './validateMetricYaml';
const meta: Meta<typeof MyYamlEditor> = {
title: 'Metrics/Files/MyYamlEditor',
component: MyYamlEditor,
tags: ['autodocs'],
parameters: {
layout: 'centered'
},
decorators: [
(Story) => (
<div className="m-12 h-[600px] min-h-[600px] w-[800px] min-w-[800px] border">
<Story />
</div>
)
]
};
export default meta;
type Story = StoryObj<typeof MyYamlEditor>;
export const Default: Story = {
args: {}
};

View File

@ -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);
});
});

View File

@ -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 siblings 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<any>(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 (
<MonacoEditor
width="800"
height="600"
language="yaml"
className="h-full min-h-[600px] w-full min-w-[800px] border"
theme="vs-light"
value={initialValue}
onMount={editorDidMount}
options={{
automaticLayout: true
}}
/>
);
};