From 371402c8b5da2744dd95c1042c6d81c5c07da86d Mon Sep 17 00:00:00 2001 From: dvankeke Date: Wed, 8 Oct 2025 16:12:43 +0200 Subject: [PATCH 1/7] feat: validation schema --- src/components/Catalog.tsx | 4 +++- src/pages/workloads/create-edit/WorkloadsCodeEditor.tsx | 2 +- src/pages/workloads/create-edit/WorkloadsCreateEditPage.tsx | 5 ++++- 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/src/components/Catalog.tsx b/src/components/Catalog.tsx index 5240b991..4dbe6cee 100644 --- a/src/components/Catalog.tsx +++ b/src/components/Catalog.tsx @@ -80,6 +80,7 @@ interface Props extends CrudProps { workload?: any workloadName?: string values?: any + valuesSchema?: any createWorkload: any updateWorkload: any deleteWorkload: any @@ -90,6 +91,7 @@ export default function ({ workload, workloadName, values, + valuesSchema, createWorkload, updateWorkload, deleteWorkload, @@ -222,7 +224,7 @@ export default function ({ {...other} /> - + diff --git a/src/pages/workloads/create-edit/WorkloadsCodeEditor.tsx b/src/pages/workloads/create-edit/WorkloadsCodeEditor.tsx index 8316d22c..77445180 100644 --- a/src/pages/workloads/create-edit/WorkloadsCodeEditor.tsx +++ b/src/pages/workloads/create-edit/WorkloadsCodeEditor.tsx @@ -74,7 +74,7 @@ export default function CodeEditor({ } catch { return } - const validate = ajv.compile(validationSchema || {}) + const validate = ajv.compile(JSON.parse(validationSchema) || {}) const isValid = validate(parsedYaml) setValidationErrors(!isValid ? validate.errors ?? [] : []) setValid?.(isValid) diff --git a/src/pages/workloads/create-edit/WorkloadsCreateEditPage.tsx b/src/pages/workloads/create-edit/WorkloadsCreateEditPage.tsx index 8ae7e477..78618af6 100644 --- a/src/pages/workloads/create-edit/WorkloadsCreateEditPage.tsx +++ b/src/pages/workloads/create-edit/WorkloadsCreateEditPage.tsx @@ -63,16 +63,18 @@ export default function WorkloadsCreateEditPage({ chartDescription: helmChartDescription, name: path, values, + valuesSchema, icon, } = item const chartMetadata = { helmChartVersion, helmChartDescription } - setCatalogItem({ chartMetadata, path, values, url, icon }) + setCatalogItem({ chartMetadata, path, values, valuesSchema, url, icon }) }) } }, [workload]) const workloadData = workloadName ? workload : catalogItem const valuesData = workloadName ? values?.values : catalogItem?.values + const valuesSchema = catalogItem?.valuesSchema useEffect(() => { if (isDirty !== false) return @@ -89,6 +91,7 @@ export default function WorkloadsCreateEditPage({ workload={workloadData} workloadName={workloadName} values={valuesData} + valuesSchema={valuesSchema} createWorkload={createWorkload} updateWorkload={updateWorkload} deleteWorkload={deleteWorkload} From f06a3ee3d09fcec955e1ba3e85763420117cbc9c Mon Sep 17 00:00:00 2001 From: dvankeke Date: Wed, 15 Oct 2025 15:19:54 +0200 Subject: [PATCH 2/7] feat: errors in code editor, partially finished --- .../create-edit/WorkloadsCodeEditor.tsx | 280 ++++++++++++++---- 1 file changed, 215 insertions(+), 65 deletions(-) diff --git a/src/pages/workloads/create-edit/WorkloadsCodeEditor.tsx b/src/pages/workloads/create-edit/WorkloadsCodeEditor.tsx index 77445180..8a9d5d61 100644 --- a/src/pages/workloads/create-edit/WorkloadsCodeEditor.tsx +++ b/src/pages/workloads/create-edit/WorkloadsCodeEditor.tsx @@ -1,30 +1,27 @@ /* eslint-disable @typescript-eslint/restrict-plus-operands */ -import React, { useState } from 'react' +import React, { useEffect, useMemo, useState } from 'react' import { Editor } from '@monaco-editor/react' -import { Box } from '@mui/material' -import Ajv from 'ajv' +import * as monaco from 'monaco-editor' +import Ajv, { ErrorObject } from 'ajv' import { makeStyles } from 'tss-react/mui' -import YAML from 'yaml' +import YAML, { Document, Pair, Scalar, Node as YamlNode, isMap } from 'yaml' import useSettings from 'hooks/useSettings' -const useStyles = makeStyles()((theme) => { - const p = theme.palette - return { - root: { border: '1px solid transparent' }, - invalid: { border: '1px solid red' }, - errorMessageWrapper: { - marginTop: theme.spacing(2), - backgroundColor: '#feefef', - border: '1px solid red', - color: 'red', - padding: '0px 5px', - }, - errorMessage: { - whiteSpace: 'pre-wrap', - marginLeft: theme.spacing(3), - }, - } -}) +const useStyles = makeStyles()((theme) => ({ + root: { border: '1px solid transparent' }, + invalid: { border: '1px solid red' }, + errorMessageWrapper: { + marginTop: theme.spacing(2), + backgroundColor: '#feefef', + border: '1px solid red', + color: 'red', + padding: '0px 5px', + }, + errorMessage: { + whiteSpace: 'pre-wrap', + marginLeft: theme.spacing(3), + }, +})) interface Props { code?: string @@ -34,6 +31,9 @@ interface Props { validationSchema?: any } +// constant required for Monaco editor to group errors +const OWNER = 'yaml-ajv-validation' + export default function CodeEditor({ code = '', onChange, @@ -43,81 +43,231 @@ export default function CodeEditor({ ...props }: Props): React.ReactElement { const [valid, setLocalValid] = useState(true) - const [error, setError] = useState('') - const [validationErrors, setValidationErrors] = useState([]) + const [yamlErrorMsg, setYamlErrorMsg] = useState('') + const [editorInstance, setEditorInstance] = useState(null) + const [monacoInstance, setMonacoInstance] = useState(null) const { themeMode } = useSettings() const isLight = themeMode === 'light' const { classes } = useStyles() - const ajv = new Ajv({ allErrors: true, strict: false, strictTypes: false, verbose: true }) - const fromYaml = (yaml: string): any | undefined => { + // Initiate AJV (Another JSON Validator) + const ajv = useMemo(() => new Ajv({ allErrors: true, strict: false, strictTypes: false, verbose: true }), []) + const validator = useMemo(() => { + let schemaObj: any = {} + try { + schemaObj = typeof validationSchema === 'string' ? JSON.parse(validationSchema) : validationSchema || {} + } catch (e) { + schemaObj = {} + } try { - const obj = YAML.parse(yaml) - if (typeof obj !== 'object') throw new Error(`invalid object parsed from yaml: ${obj}`) - setLocalValid(true) - setError('') - setValid?.(true) - return obj - } catch (e: any) { - console.error('YAML parse error:', e) - setError(e.message) + return ajv.compile(schemaObj) + } catch (e) { + return ((_: unknown) => true) as unknown as ReturnType + } + }, [ajv, validationSchema]) + + // Keep Monaco refs + const handleEditorDidMount = (editor, monaco) => { + setEditorInstance(editor) + setMonacoInstance(monaco) + } + + // Utility: clear all our markers + const clearMarkers = () => { + if (!monacoInstance || !editorInstance) return + const model = editorInstance.getModel() + if (model) monacoInstance.editor.setModelMarkers(model, OWNER, []) + } + + // JSON Pointer unescape for AJV instancePath segments + const unescapeJsonPtr = (s: string) => s.replace(/~1/g, '/').replace(/~0/g, '~') + + // Convert AJV instancePath to YAML path segments (numbers for arrays) + const pathFromInstancePath = (p: string): Array => { + if (!p || p === '') return [] + return p + .replace(/^\//, '') + .split('/') + .filter(Boolean) + .map(unescapeJsonPtr) + .map((seg) => (/^\d+$/.test(seg) ? Number(seg) : seg)) + } + + // Walk YAML Document to node for a given path + const getNodeAtPath = (doc: Document.Parsed, segments: Array): YamlNode | null => { + // YAML doc API can do this directly: + // keepScalar=true → returns Scalar node instead of unwrapped value + const node = doc.getIn(segments as any, true) as YamlNode | null + return node ?? null + } + + // For additionalProperties, we want the Pair.key node of the extra prop + const getAdditionalPropertyKeyNode = (parentNode: YamlNode | null, propName: string): YamlNode | null => { + if (!parentNode || !isMap(parentNode)) return null + const map = parentNode + const pair = map.items.find( + (p: Pair) => String((p.key as Scalar | YamlNode)?.toJSON?.() ?? (p.key as any)?.value ?? '') === propName, + ) + return pair?.key ?? null + } + + // Prefer key/value node ranges if available; otherwise fallback to node.range + const nodeRange = (node: YamlNode | null, prefer: 'key' | 'value' | 'node' = 'node'): [number, number] | null => { + if (!node) return null + // For Pair we can choose key or value + if ((node as any).key || (node as any).value) { + const pair = node as unknown as Pair + const target = prefer === 'key' ? pair.key : prefer === 'value' ? pair.value : pair.value ?? pair.key + if (target && target.range) return [target.range[0], target.range[2]] + if (pair.key?.range) return [pair.key.range[0], pair.key.range[2]] + if (pair.value?.range) return [pair.value.range[0], pair.value.range[2]] + } + if ((node as any).range) { + const r = (node as any).range as [number, number, number] + return [r[0], r[2]] + } + return null + } + + const buildMarker = (model: monaco.editor.ITextModel, message: string, startOffset: number, endOffset: number) => { + const start = model.getPositionAt(Math.max(0, startOffset)) + const end = model.getPositionAt(Math.max(startOffset + 1, endOffset)) + return { + severity: monacoInstance.MarkerSeverity.Error, + message, + startLineNumber: start.lineNumber, + startColumn: start.column, + endLineNumber: end.lineNumber, + endColumn: end.column, + } + } + + const validateAndMark = (text: string) => { + if (!monacoInstance || !editorInstance) return + const model = editorInstance.getModel() + if (!model) return + + // Always start clean + monacoInstance.editor.setModelMarkers(model, OWNER, []) + setYamlErrorMsg('') + + // 1) Parse YAML into Document AST + const doc = YAML.parseDocument(text, { keepCstNodes: true, keepNodeTypes: true }) + if (doc.errors.length > 0) { + // Mark syntax errors precisely + const markers = doc.errors.map((err) => { + // err.pos can be a number or [start, end]; use first position + const pos = Array.isArray(err.pos) ? err.pos[0] : err.pos + const start = typeof pos === 'number' ? pos : 0 + const end = start + 1 + return buildMarker(model, err.message, start, end) + }) + monacoInstance.editor.setModelMarkers(model, OWNER, markers) setLocalValid(false) setValid?.(false) - return undefined + setYamlErrorMsg(doc.errors.map((e) => e.message).join('\n')) + return } - } - const validateCode = (newValue: string) => { - let parsedYaml + // 2) Convert Document to JS for AJV (avoid YAML.parse twice) + const data = doc.toJS({ mapAsMap: false }) + + // 3) AJV validation + let isValid = true + let errors: ErrorObject[] = [] try { - parsedYaml = YAML.parse(newValue) + isValid = validator(data) as unknown as boolean + errors = (validator as any).errors || [] } catch { - return + // If the compiled validator throws, treat as valid to avoid blocking typing + isValid = true + errors = [] } - const validate = ajv.compile(JSON.parse(validationSchema) || {}) - const isValid = validate(parsedYaml) - setValidationErrors(!isValid ? validate.errors ?? [] : []) + + setLocalValid(isValid) setValid?.(isValid) + + if (isValid || errors.length === 0) { + monacoInstance.editor.setModelMarkers(model, OWNER, []) + return + } + + // 4) Map AJV errors -> YAML nodes -> precise markers + const markers = errors + .map((err) => { + const segs = pathFromInstancePath(err.instancePath) + + // Special handling by keyword + if (err.keyword === 'additionalProperties') { + const parentNode = getNodeAtPath(doc, segs) + const ap = (err.params as any)?.additionalProperty + const keyNode = getAdditionalPropertyKeyNode(parentNode, ap) + const r = nodeRange(keyNode ?? parentNode, keyNode ? 'key' : 'node') + const msg = `Property "${ap}" is not allowed${segs.length ? ` at /${segs.join('/')}` : ''}` + if (r) return buildMarker(model, msg, r[0], r[1]) + } + + if (err.keyword === 'required') { + // Missing property: highlight the parent object + const parentNode = getNodeAtPath(doc, segs) + const missing = (err.params as any)?.missingProperty + const r = nodeRange(parentNode, 'node') + const msg = `Missing required property "${missing}"${segs.length ? ` at /${segs.join('/')}` : ''}` + if (r) return buildMarker(model, msg, r[0], r[1]) + } + + // Default: highlight the node at the exact path (value if possible) + let node = getNodeAtPath(doc, segs) + // If path points to a Pair's value, doc.getIn already returns that node. + // If nothing found (e.g., patternProperties), fall back to parent + if (!node && segs.length > 0) node = getNodeAtPath(doc, segs.slice(0, -1)) + + const msg = + err.keyword === 'type' + ? `${segs.join('.') || '(root)'} ${err.message}` + : `${segs.join('.') || '(root)'} ${err.message}` + + const r = nodeRange(node, 'node') + if (r) return buildMarker(model, msg, r[0], r[1]) + + return null + }) + .filter(Boolean) as monaco.editor.IMarkerData[] + + monacoInstance.editor.setModelMarkers(model, OWNER, markers) } const onChangeHandler = (newValue: string | undefined) => { if (!newValue) return - onChange?.(newValue) // keep YAML in parent - fromYaml(newValue) - validateCode(newValue) + onChange?.(newValue) + validateAndMark(newValue) } + // Validate once on mount with initial code + useEffect(() => { + if (editorInstance && typeof code === 'string') validateAndMark(code) + + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [editorInstance, validator]) + return ( <> 0 ? ` ${classes.invalid}` : ''}`} + className={`${classes.root}${!valid ? ` ${classes.invalid}` : ''}`} height='600px' theme={isLight ? 'light' : 'vs-dark'} defaultValue={code} language='yaml' + onMount={handleEditorDidMount} onChange={onChangeHandler} options={{ readOnly: disabled, automaticLayout: true }} {...props} /> - {error && ( + {/* {yamlErrorMsg && ( -

{error}

+

{yamlErrorMsg}

- )} - {validationErrors.map((err, index) => { - const lastSegment = err.instancePath.split('/').pop() - const instancePath = err.instancePath ? `at ${err.instancePath}` : '' - const errorMessage = - err.keyword === 'additionalProperties' - ? `Additional property "${err.params.additionalProperty}" is not allowed ${instancePath}` - : `"${lastSegment}" ${err.message} ${instancePath}` - return ( - // eslint-disable-next-line react/no-array-index-key - -

{errorMessage}

-
- ) - })} + )} */} ) } From 6012d68a111e0fd672bcc6baa1b29ac246e6c0c0 Mon Sep 17 00:00:00 2001 From: dvankeke Date: Mon, 20 Oct 2025 13:44:07 +0200 Subject: [PATCH 3/7] fix: editor enhancements --- src/components/Catalog.tsx | 5 -- .../create-edit/WorkloadsCodeEditor.tsx | 47 +++++++++---------- 2 files changed, 21 insertions(+), 31 deletions(-) diff --git a/src/components/Catalog.tsx b/src/components/Catalog.tsx index 4dbe6cee..5e48671a 100644 --- a/src/components/Catalog.tsx +++ b/src/components/Catalog.tsx @@ -11,7 +11,6 @@ import { applyAclToUiSchema, getSpec } from 'common/api-spec' import { useAppDispatch } from 'redux/hooks' import { setError } from 'redux/reducers' import { makeStyles } from 'tss-react/mui' -import InformationBanner from './InformationBanner' import CodeEditor from '../pages/workloads/create-edit/WorkloadsCodeEditor' import Form from './rjsf/Form' import DeleteButton from './DeleteButton' @@ -208,10 +207,6 @@ export default function ({ )}
- {workloadValuesJson?.image && checkImageFields(workloadValuesJson?.image) && ( - - )} -
{ setEditorInstance(editor) setMonacoInstance(monaco) } - // Utility: clear all our markers - const clearMarkers = () => { - if (!monacoInstance || !editorInstance) return - const model = editorInstance.getModel() - if (model) monacoInstance.editor.setModelMarkers(model, OWNER, []) - } - // JSON Pointer unescape for AJV instancePath segments + // e.g. '/foo~1bar' → '/foo/bar' const unescapeJsonPtr = (s: string) => s.replace(/~1/g, '/').replace(/~0/g, '~') // Convert AJV instancePath to YAML path segments (numbers for arrays) @@ -197,6 +191,14 @@ export default function CodeEditor({ .map((err) => { const segs = pathFromInstancePath(err.instancePath) + if (err.keyword === 'enum' && Array.isArray((err.params as any)?.allowedValues)) { + const allowed = (err.params as any).allowedValues.join(', ') + const node = getNodeAtPath(doc, segs) + const r = nodeRange(node, 'node') + const msg = `${segs.join('.') || '(root)'} must be one of: ${allowed}` + if (r) return buildMarker(model, msg, r[0], r[1]) + } + // Special handling by keyword if (err.keyword === 'additionalProperties') { const parentNode = getNodeAtPath(doc, segs) @@ -251,23 +253,16 @@ export default function CodeEditor({ }, [editorInstance, validator]) return ( - <> - - {/* {yamlErrorMsg && ( - -

{yamlErrorMsg}

-
- )} */} - + ) } From c975809af5789c6e2c80b055888c5c7f0e75971c Mon Sep 17 00:00:00 2001 From: dvankeke Date: Mon, 20 Oct 2025 14:17:36 +0200 Subject: [PATCH 4/7] fix: type errors --- .../create-edit/WorkloadsCodeEditor.tsx | 61 ++++++++++++------- 1 file changed, 40 insertions(+), 21 deletions(-) diff --git a/src/pages/workloads/create-edit/WorkloadsCodeEditor.tsx b/src/pages/workloads/create-edit/WorkloadsCodeEditor.tsx index 09e1acd3..7be50ba3 100644 --- a/src/pages/workloads/create-edit/WorkloadsCodeEditor.tsx +++ b/src/pages/workloads/create-edit/WorkloadsCodeEditor.tsx @@ -4,7 +4,7 @@ import { Editor } from '@monaco-editor/react' import * as monaco from 'monaco-editor' import Ajv, { ErrorObject } from 'ajv' import { makeStyles } from 'tss-react/mui' -import YAML, { Document, Pair, Scalar, Node as YamlNode, isMap } from 'yaml' +import YAML, { Document, Pair, Node as YamlNode, isMap } from 'yaml' import useSettings from 'hooks/useSettings' const useStyles = makeStyles()((theme) => ({ @@ -43,7 +43,6 @@ export default function CodeEditor({ ...props }: Props): React.ReactElement { const [valid, setLocalValid] = useState(true) - const [yamlErrorMsg, setYamlErrorMsg] = useState('') const [editorInstance, setEditorInstance] = useState(null) const [monacoInstance, setMonacoInstance] = useState(null) const { themeMode } = useSettings() @@ -98,28 +97,49 @@ export default function CodeEditor({ // For additionalProperties, we want the Pair.key node of the extra prop const getAdditionalPropertyKeyNode = (parentNode: YamlNode | null, propName: string): YamlNode | null => { if (!parentNode || !isMap(parentNode)) return null - const map = parentNode - const pair = map.items.find( - (p: Pair) => String((p.key as Scalar | YamlNode)?.toJSON?.() ?? (p.key as any)?.value ?? '') === propName, - ) + const map = parentNode as unknown as { items: Pair[] } + const pair = map.items.find((p) => { + const key = p.key + const keyValue = String((key as any)?.toJSON?.() ?? (key as any)?.value ?? '') + return keyValue === propName + }) return pair?.key ?? null } // Prefer key/value node ranges if available; otherwise fallback to node.range + // 'node' = 'node' is not a mistake, it's setting the default for 'prefer' parameter const nodeRange = (node: YamlNode | null, prefer: 'key' | 'value' | 'node' = 'node'): [number, number] | null => { if (!node) return null - // For Pair we can choose key or value - if ((node as any).key || (node as any).value) { + + // Handle YAML key-value pair nodes + const isPair = (node as any).key || (node as any).value + if (isPair) { const pair = node as unknown as Pair - const target = prefer === 'key' ? pair.key : prefer === 'value' ? pair.value : pair.value ?? pair.key - if (target && target.range) return [target.range[0], target.range[2]] - if (pair.key?.range) return [pair.key.range[0], pair.key.range[2]] - if (pair.value?.range) return [pair.value.range[0], pair.value.range[2]] - } - if ((node as any).range) { - const r = (node as any).range as [number, number, number] - return [r[0], r[2]] + let target: YamlNode | undefined + + // Choose which part of the Pair to highlight + switch (prefer) { + case 'key': + target = pair.key as YamlNode + break + case 'value': + target = pair.value as YamlNode + break + default: // 'node' (prefer value, fallback to key) + target = (pair.value as YamlNode) ?? (pair.key as YamlNode) + break + } + + // Try the chosen node first, then fallback to key/value if needed + if (target?.range) return [target.range[0], target.range[2]] + if ((pair.key as YamlNode)?.range) return [(pair.key as YamlNode).range[0], (pair.key as YamlNode).range[2]] + if ((pair.value as YamlNode)?.range) return [(pair.value as YamlNode).range[0], (pair.value as YamlNode).range[2]] } + + // Handle plain YAML nodes (Scalar, Sequence, etc.) + const range = (node as any).range as [number, number, number] | undefined + if (range) return [range[0], range[2]] + return null } @@ -143,10 +163,9 @@ export default function CodeEditor({ // Always start clean monacoInstance.editor.setModelMarkers(model, OWNER, []) - setYamlErrorMsg('') // 1) Parse YAML into Document AST - const doc = YAML.parseDocument(text, { keepCstNodes: true, keepNodeTypes: true }) + const doc = YAML.parseDocument(text) if (doc.errors.length > 0) { // Mark syntax errors precisely const markers = doc.errors.map((err) => { @@ -159,7 +178,6 @@ export default function CodeEditor({ monacoInstance.editor.setModelMarkers(model, OWNER, markers) setLocalValid(false) setValid?.(false) - setYamlErrorMsg(doc.errors.map((e) => e.message).join('\n')) return } @@ -191,6 +209,7 @@ export default function CodeEditor({ .map((err) => { const segs = pathFromInstancePath(err.instancePath) + // handle Enum keywords, add allowed enum types to error if (err.keyword === 'enum' && Array.isArray((err.params as any)?.allowedValues)) { const allowed = (err.params as any).allowedValues.join(', ') const node = getNodeAtPath(doc, segs) @@ -199,7 +218,7 @@ export default function CodeEditor({ if (r) return buildMarker(model, msg, r[0], r[1]) } - // Special handling by keyword + // handle additional Properties keywords, prevent non existing properties from being added if (err.keyword === 'additionalProperties') { const parentNode = getNodeAtPath(doc, segs) const ap = (err.params as any)?.additionalProperty @@ -209,8 +228,8 @@ export default function CodeEditor({ if (r) return buildMarker(model, msg, r[0], r[1]) } + // handle required keywords, highlight parent object with missing property if (err.keyword === 'required') { - // Missing property: highlight the parent object const parentNode = getNodeAtPath(doc, segs) const missing = (err.params as any)?.missingProperty const r = nodeRange(parentNode, 'node') From d2e4e49922dd6e9dc4d93c82120dbdb7c7f82644 Mon Sep 17 00:00:00 2001 From: dvankeke Date: Mon, 20 Oct 2025 14:22:14 +0200 Subject: [PATCH 5/7] feat: added block of documentation --- .../create-edit/WorkloadsCodeEditor.tsx | 73 +++++++++++++++++++ 1 file changed, 73 insertions(+) diff --git a/src/pages/workloads/create-edit/WorkloadsCodeEditor.tsx b/src/pages/workloads/create-edit/WorkloadsCodeEditor.tsx index 7be50ba3..fbf4dada 100644 --- a/src/pages/workloads/create-edit/WorkloadsCodeEditor.tsx +++ b/src/pages/workloads/create-edit/WorkloadsCodeEditor.tsx @@ -34,6 +34,79 @@ interface Props { // constant required for Monaco editor to group errors const OWNER = 'yaml-ajv-validation' +/** + * CodeEditor – YAML-aware validation editor with AJV-based schema enforcement and Monaco integration. + * + * ### Overview + * This editor provides live YAML validation with inline error markers using Monaco Editor. + * It combines three systems: + * - **YAML parser (`yaml`)** → Parses and produces a concrete syntax tree (CST) with node ranges. + * - **AJV (`ajv`)** → Validates the parsed YAML against a JSON Schema. + * - **Monaco (`@monaco-editor/react`)** → Displays code, errors, and highlights inline. + * + * The validation pipeline works in four phases: + * + * --- + * ### 1. YAML Parsing + * The editor uses `YAML.parseDocument(text)` to produce a `Document` object. + * This keeps positional metadata for every node (`range: [start, middle, end]`), + * which we later use to map AJV errors to character offsets. + * + * - If `doc.errors` is non-empty, the YAML itself is invalid (e.g., bad indentation, unclosed quote). + * These are marked immediately as syntax errors by converting `error.pos` values into + * Monaco markers (`setModelMarkers`). + * + * --- + * ### 2. JSON Schema Validation (AJV) + * If YAML syntax is valid, the editor converts the document to plain JS (`doc.toJS()`) + * and runs it through an AJV validator compiled from `validationSchema`. + * AJV emits a list of `ErrorObject`s, each describing a schema rule violation: + * - `instancePath`: JSON Pointer path to the failing value + * - `keyword`: the failing rule ("required", "type", "enum", etc.) + * - `params`: rule-specific details (e.g., `missingProperty`) + * + * --- + * ### 3. Error-to-YAML Mapping + * AJV reports errors as JSON paths (like `/image/repository`), which must be mapped back + * to YAML syntax nodes. The editor reconstructs this path and locates the corresponding + * YAML AST node via: + * + * getNodeAtPath(doc, pathSegments) + * + * Once the relevant node is found, its `range` is extracted via: + * + * nodeRange(node, prefer) + * + * The `prefer` argument controls whether the marker highlights the key, value, or both. + * For example: + * - `"required"` → highlight the parent object + * - `"enum"` → highlight the exact scalar value + * - `"additionalProperties"` → highlight the extra property key + * + * Each rule type (AJV keyword) has a custom handler that determines + * which YAML node to highlight and what message to show. + * + * --- + * ### 4. Marker Creation (Monaco) + * Once a range `[start, end]` in the YAML source is identified, the function `buildMarker` + * converts those offsets into Monaco’s coordinate system: + * + * const start = model.getPositionAt(startOffset) + * const end = model.getPositionAt(endOffset) + * + * Then it builds a `monaco.editor.IMarkerData` object with: + * - `severity: monaco.MarkerSeverity.Error` + * - `message: string` (from AJV error) + * - `startLineNumber`, `startColumn`, `endLineNumber`, `endColumn` + * + * All markers are then grouped and applied via: + * + * monacoInstance.editor.setModelMarkers(model, OWNER, markers) + * + * The `OWNER` constant (`'yaml-ajv-validation'`) scopes markers for this validator + * so they can be cleared or replaced easily between validation runs. + */ + export default function CodeEditor({ code = '', onChange, From ee63d72de19c141044cb44b604719040b61b7e9c Mon Sep 17 00:00:00 2001 From: dvankeke Date: Mon, 20 Oct 2025 14:32:29 +0200 Subject: [PATCH 6/7] fix: capital letters and extra line of doc --- .../workloads/create-edit/WorkloadsCodeEditor.tsx | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/pages/workloads/create-edit/WorkloadsCodeEditor.tsx b/src/pages/workloads/create-edit/WorkloadsCodeEditor.tsx index fbf4dada..59a31799 100644 --- a/src/pages/workloads/create-edit/WorkloadsCodeEditor.tsx +++ b/src/pages/workloads/create-edit/WorkloadsCodeEditor.tsx @@ -282,7 +282,11 @@ export default function CodeEditor({ .map((err) => { const segs = pathFromInstancePath(err.instancePath) - // handle Enum keywords, add allowed enum types to error + /* + *You can add custom logic here for each keyword if desired + */ + + // Handle Enum keywords, add allowed enum types to error if (err.keyword === 'enum' && Array.isArray((err.params as any)?.allowedValues)) { const allowed = (err.params as any).allowedValues.join(', ') const node = getNodeAtPath(doc, segs) @@ -291,7 +295,7 @@ export default function CodeEditor({ if (r) return buildMarker(model, msg, r[0], r[1]) } - // handle additional Properties keywords, prevent non existing properties from being added + // Handle additional Properties keywords, prevent non existing properties from being added if (err.keyword === 'additionalProperties') { const parentNode = getNodeAtPath(doc, segs) const ap = (err.params as any)?.additionalProperty @@ -301,7 +305,7 @@ export default function CodeEditor({ if (r) return buildMarker(model, msg, r[0], r[1]) } - // handle required keywords, highlight parent object with missing property + // Handle required keywords, highlight parent object with missing property if (err.keyword === 'required') { const parentNode = getNodeAtPath(doc, segs) const missing = (err.params as any)?.missingProperty From c2288f9d91630452710b127369657921076413d7 Mon Sep 17 00:00:00 2001 From: dvankeke Date: Wed, 22 Oct 2025 09:10:41 +0200 Subject: [PATCH 7/7] fix: small cleanup --- src/pages/workloads/create-edit/WorkloadsCodeEditor.tsx | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/src/pages/workloads/create-edit/WorkloadsCodeEditor.tsx b/src/pages/workloads/create-edit/WorkloadsCodeEditor.tsx index 59a31799..d98c59f7 100644 --- a/src/pages/workloads/create-edit/WorkloadsCodeEditor.tsx +++ b/src/pages/workloads/create-edit/WorkloadsCodeEditor.tsx @@ -1,4 +1,3 @@ -/* eslint-disable @typescript-eslint/restrict-plus-operands */ import React, { useEffect, useMemo, useState } from 'react' import { Editor } from '@monaco-editor/react' import * as monaco from 'monaco-editor' @@ -320,10 +319,7 @@ export default function CodeEditor({ // If nothing found (e.g., patternProperties), fall back to parent if (!node && segs.length > 0) node = getNodeAtPath(doc, segs.slice(0, -1)) - const msg = - err.keyword === 'type' - ? `${segs.join('.') || '(root)'} ${err.message}` - : `${segs.join('.') || '(root)'} ${err.message}` + const msg = `${segs.join('.') || '(root)'} ${err.message}` const r = nodeRange(node, 'node') if (r) return buildMarker(model, msg, r[0], r[1]) @@ -344,8 +340,6 @@ export default function CodeEditor({ // Validate once on mount with initial code useEffect(() => { if (editorInstance && typeof code === 'string') validateAndMark(code) - - // eslint-disable-next-line react-hooks/exhaustive-deps }, [editorInstance, validator]) return (