diff --git a/package.json b/package.json index f34f2796b0..0ce3583b07 100644 --- a/package.json +++ b/package.json @@ -3,6 +3,7 @@ "@cloudoperators/juno-messages-provider": "0.2.5", "@cloudoperators/juno-ui-components": "3.1.1", "@codemirror/commands": "^6.10.2", + "@codemirror/lang-json": "^6.0.2", "@codemirror/lang-yaml": "^6.1.2", "@codemirror/language": "^6.12.2", "@codemirror/state": "^6.5.4", diff --git a/plugins/kubernetes_ng/app/javascript/widgets/app/components/YamlEditor/index.tsx b/plugins/kubernetes_ng/app/javascript/widgets/app/components/YamlEditor/index.tsx index 4f4c162550..3989fb362a 100644 --- a/plugins/kubernetes_ng/app/javascript/widgets/app/components/YamlEditor/index.tsx +++ b/plugins/kubernetes_ng/app/javascript/widgets/app/components/YamlEditor/index.tsx @@ -3,7 +3,7 @@ import { Stack } from "@cloudoperators/juno-ui-components" import DisableableButton from "../DisableableButton" import { useCodeMirror } from "./useCodeMirror" import { useEditorHeight } from "./useEditorHeight" -import { useYamlSerialization } from "./useYamlSerialization" +import { useSerialization } from "./useSerialization" import { useYamlEditorState } from "./useYamlEditorState" import { useNavigationBlock } from "./useNavigationBlock" import { CancelConfirmDialog, ResourceVersionConflictDialog, NavigationBlockDialog } from "./dialogs" @@ -19,6 +19,7 @@ export interface YamlEditorProps extends Omit(null) const editorContainerRef = useRef(null) - // Serialize resource to YAML - const { yamlContent, error } = useYamlSerialization(resource, onError) + // Serialize resource to YAML or JSON + const { content, error } = useSerialization(resource, format, onError) // Calculate dynamic editor height const editorHeight = useEditorHeight(containerRef) @@ -44,7 +46,8 @@ export default function YamlEditor({ // Manage editor state and actions const editorState = useYamlEditorState({ resource, - yamlContent, + content, + format, onSave, onError, onEdit, @@ -54,13 +57,14 @@ export default function YamlEditor({ // Initialize and manage CodeMirror editor useCodeMirror({ containerRef: editorContainerRef, - initialContent: yamlContent, + initialContent: content, editorHeight, + format, isEditable: editorState.isEditable, error, - editedYaml: editorState.editedYaml, - yamlContent, - onDocChange: editorState.setEditedYaml, + editedContent: editorState.editedContent, + content, + onDocChange: editorState.setEditedContent, }) // Block navigation when there are unsaved changes @@ -75,7 +79,7 @@ export default function YamlEditor({ style={{ height: `${TOOLBAR_HEIGHT}px` }} >
- {editorState.isEditable ? "Edit Mode" : "Read Mode"} + {editorState.isEditable ? `Edit Mode (${format.toUpperCase()})` : `Read Mode (${format.toUpperCase()})`}
diff --git a/plugins/kubernetes_ng/app/javascript/widgets/app/components/YamlEditor/useCodeMirror.ts b/plugins/kubernetes_ng/app/javascript/widgets/app/components/YamlEditor/useCodeMirror.ts index 929bdde3f8..44d834f776 100644 --- a/plugins/kubernetes_ng/app/javascript/widgets/app/components/YamlEditor/useCodeMirror.ts +++ b/plugins/kubernetes_ng/app/javascript/widgets/app/components/YamlEditor/useCodeMirror.ts @@ -2,6 +2,7 @@ import { useEffect, useRef } from "react" import { EditorView, highlightWhitespace, highlightActiveLine, lineNumbers, keymap } from "@codemirror/view" import { EditorState, Compartment } from "@codemirror/state" import { yaml } from "@codemirror/lang-yaml" +import { json } from "@codemirror/lang-json" import { defaultHighlightStyle, syntaxHighlighting } from "@codemirror/language" import { indentWithTab } from "@codemirror/commands" @@ -9,6 +10,7 @@ import { indentWithTab } from "@codemirror/commands" const editableCompartment = new Compartment() const heightCompartment = new Compartment() const ariaCompartment = new Compartment() +const languageCompartment = new Compartment() function createEditableExtension(value: boolean) { return EditorView.editable.of(value) @@ -25,21 +27,27 @@ function createHeightExtension(height: string) { }) } -function createAriaExtension(isEditable: boolean) { +function createAriaExtension(isEditable: boolean, format: "yaml" | "json") { + const formatName = format.toUpperCase() return EditorView.contentAttributes.of({ - "aria-label": isEditable ? "YAML data editor" : "YAML data viewer (read-only)", + "aria-label": isEditable ? `${formatName} data editor` : `${formatName} data viewer (read-only)`, "aria-readonly": isEditable ? "false" : "true", }) } +function createLanguageExtension(format: "yaml" | "json") { + return format === "json" ? json() : yaml() +} + function createEditorExtensions( editorHeight: string, + format: "yaml" | "json", isEditable: boolean, onDocChange: (value: string) => void, isUpdatingProgrammaticallyRef: React.MutableRefObject ) { return [ - yaml(), + languageCompartment.of(createLanguageExtension(format)), syntaxHighlighting(defaultHighlightStyle), highlightWhitespace(), highlightActiveLine(), @@ -60,7 +68,7 @@ function createEditorExtensions( }, }), EditorView.editorAttributes.of({ class: "yaml-editor-content" }), - ariaCompartment.of(createAriaExtension(isEditable)), + ariaCompartment.of(createAriaExtension(isEditable, format)), editableCompartment.of(createEditableExtension(false)), heightCompartment.of(createHeightExtension(editorHeight)), EditorView.updateListener.of((update) => { @@ -97,10 +105,11 @@ interface UseCodeMirrorOptions { containerRef: React.RefObject initialContent: string editorHeight: string + format: "yaml" | "json" isEditable: boolean error: string - editedYaml: string - yamlContent: string + editedContent: string + content: string onDocChange: (value: string) => void } @@ -108,10 +117,11 @@ export function useCodeMirror({ containerRef, initialContent, editorHeight, + format, isEditable, error, - editedYaml, - yamlContent, + editedContent, + content, onDocChange, }: UseCodeMirrorOptions) { const editorViewRef = useRef(null) @@ -120,6 +130,7 @@ export function useCodeMirror({ // Store initial values in refs to avoid triggering effect re-runs const initialContentRef = useRef(initialContent) const initialHeightRef = useRef(editorHeight) + const initialFormatRef = useRef(format) const onDocChangeRef = useRef(onDocChange) // Keep onDocChange ref up to date @@ -135,6 +146,7 @@ export function useCodeMirror({ doc: initialContentRef.current, extensions: createEditorExtensions( initialHeightRef.current, + initialFormatRef.current, false, (value) => onDocChangeRef.current(value), isUpdatingProgrammaticallyRef @@ -168,10 +180,10 @@ export function useCodeMirror({ editorViewRef.current.dispatch({ effects: [ editableCompartment.reconfigure(createEditableExtension(currentEditable)), - ariaCompartment.reconfigure(createAriaExtension(currentEditable)), + ariaCompartment.reconfigure(createAriaExtension(currentEditable, format)), ], }) - }, [isEditable, error]) + }, [isEditable, error, format]) // Update height dynamically useEffect(() => { @@ -181,25 +193,33 @@ export function useCodeMirror({ }) }, [editorHeight]) - // Update editor content when yamlContent changes (external updates) - only in read-only mode + // Update language mode when format changes + useEffect(() => { + if (!editorViewRef.current) return + editorViewRef.current.dispatch({ + effects: languageCompartment.reconfigure(createLanguageExtension(format)), + }) + }, [format]) + + // Update editor content when content changes (external updates) - only in read-only mode useEffect(() => { if (!editorViewRef.current || isEditable) return const currentDoc = editorViewRef.current.state.doc.toString() - if (currentDoc !== yamlContent) { - updateEditorContent(editorViewRef.current, yamlContent, isUpdatingProgrammaticallyRef) + if (currentDoc !== content) { + updateEditorContent(editorViewRef.current, content, isUpdatingProgrammaticallyRef) } - }, [yamlContent, isEditable]) + }, [content, isEditable]) // Update editor content when entering edit mode useEffect(() => { if (!editorViewRef.current || !isEditable) return const currentDoc = editorViewRef.current.state.doc.toString() - if (editedYaml && currentDoc !== editedYaml) { - updateEditorContent(editorViewRef.current, editedYaml, isUpdatingProgrammaticallyRef) + if (editedContent && currentDoc !== editedContent) { + updateEditorContent(editorViewRef.current, editedContent, isUpdatingProgrammaticallyRef) } - }, [isEditable, editedYaml]) + }, [isEditable, editedContent]) return editorViewRef } diff --git a/plugins/kubernetes_ng/app/javascript/widgets/app/components/YamlEditor/useSerialization.ts b/plugins/kubernetes_ng/app/javascript/widgets/app/components/YamlEditor/useSerialization.ts new file mode 100644 index 0000000000..339176198c --- /dev/null +++ b/plugins/kubernetes_ng/app/javascript/widgets/app/components/YamlEditor/useSerialization.ts @@ -0,0 +1,55 @@ +import { useMemo, useEffect } from "react" +import yamlParser from "js-yaml" + +// Filter out managedFields from metadata (server-managed, not user-editable) +function filterManagedFields(resource: Record): Record { + const metadata = resource.metadata as Record | undefined + if (!metadata?.managedFields) { + return resource + } + + const filteredMetadata = { ...metadata } + delete filteredMetadata.managedFields + return { + ...resource, + metadata: filteredMetadata, + } +} + +export function useSerialization( + resource: Record, + format: "yaml" | "json", + onError?: (error: Error) => void +) { + const { content, error } = useMemo(() => { + try { + const filteredResource = filterManagedFields(resource) + + if (format === "json") { + const jsonString = JSON.stringify(filteredResource, null, 2) + return { content: jsonString, error: "" } + } else { + const yamlString = yamlParser.dump(filteredResource, { + indent: 2, + lineWidth: -1, + noRefs: true, + sortKeys: false, + schema: yamlParser.JSON_SCHEMA, + }) + return { content: yamlString, error: "" } + } + } catch (err) { + const formatName = format.toUpperCase() + return { content: "", error: `Failed to serialize object to ${formatName}: ${(err as Error).message}` } + } + }, [resource, format]) + + // Notify parent when serialization fails + useEffect(() => { + if (error) { + onError?.(new Error(error)) + } + }, [error, onError]) + + return { content, error } +} diff --git a/plugins/kubernetes_ng/app/javascript/widgets/app/components/YamlEditor/useYamlEditorState.ts b/plugins/kubernetes_ng/app/javascript/widgets/app/components/YamlEditor/useYamlEditorState.ts index 9130d6f794..067202c135 100644 --- a/plugins/kubernetes_ng/app/javascript/widgets/app/components/YamlEditor/useYamlEditorState.ts +++ b/plugins/kubernetes_ng/app/javascript/widgets/app/components/YamlEditor/useYamlEditorState.ts @@ -1,10 +1,11 @@ import { useState, useRef } from "react" import { useMutation } from "@tanstack/react-query" -import { parseYamlToObject } from "./yamlParser" +import { parseContentToObject } from "./yamlParser" interface UseYamlEditorStateOptions { resource: Record - yamlContent: string + content: string + format: "yaml" | "json" onSave: (resource: Record) => Promise onError?: (error: Error) => void onEdit?: () => void @@ -13,14 +14,15 @@ interface UseYamlEditorStateOptions { export function useYamlEditorState({ resource, - yamlContent, + content, + format, onSave, onError, onEdit, onRefresh, }: UseYamlEditorStateOptions) { const [isEditable, setIsEditable] = useState(false) - const [editedYaml, setEditedYaml] = useState("") + const [editedContent, setEditedContent] = useState("") const [showCancelDialog, setShowCancelDialog] = useState(false) const [showVersionConflictDialog, setShowVersionConflictDialog] = useState(false) const [pendingSaveData, setPendingSaveData] = useState | null>(null) @@ -30,20 +32,20 @@ export function useYamlEditorState({ mutationFn: onSave, onSuccess: () => { setIsEditable(false) - setEditedYaml("") + setEditedContent("") }, }) const exitEditMode = () => { setIsEditable(false) - setEditedYaml("") + setEditedContent("") mutation.reset() } const handleEditClick = () => { onEdit?.() if (!isEditable) { - setEditedYaml(yamlContent) + setEditedContent(content) mutation.reset() setIsEditable(true) // Capture initial resourceVersion when entering edit mode @@ -51,7 +53,7 @@ export function useYamlEditorState({ initialResourceVersionRef.current = metadata?.resourceVersion as string | undefined } else { // Check if there are unsaved changes - const hasChanges = editedYaml !== yamlContent + const hasChanges = editedContent !== content if (hasChanges) { setShowCancelDialog(true) } else { @@ -109,7 +111,7 @@ export function useYamlEditorState({ const handleSaveClick = async () => { try { - const validatedObject = parseYamlToObject(editedYaml) + const validatedObject = parseContentToObject(editedContent, format) // Refresh the resource to get the latest resourceVersion before saving if (onRefresh) { @@ -140,18 +142,18 @@ export function useYamlEditorState({ // No conflict - proceed with save mutation.mutate(validatedObject) } catch (err) { - // Show error if YAML is invalid (error already has "Invalid YAML:" prefix from parser) + // Show error if content is invalid (error already has "Invalid YAML:" or "Invalid JSON:" prefix from parser) onError?.(err as Error) } } - const hasChanges = isEditable && editedYaml !== yamlContent + const hasChanges = isEditable && editedContent !== content const isLoading = mutation.isPending || false return { isEditable, - editedYaml, - setEditedYaml, + editedContent, + setEditedContent, showCancelDialog, showVersionConflictDialog, hasChanges, diff --git a/plugins/kubernetes_ng/app/javascript/widgets/app/components/YamlEditor/yamlParser.ts b/plugins/kubernetes_ng/app/javascript/widgets/app/components/YamlEditor/yamlParser.ts index 246d3b4f71..61ffab029f 100644 --- a/plugins/kubernetes_ng/app/javascript/widgets/app/components/YamlEditor/yamlParser.ts +++ b/plugins/kubernetes_ng/app/javascript/widgets/app/components/YamlEditor/yamlParser.ts @@ -43,3 +43,36 @@ export function parseYamlToObject(yamlContent: string): Record throw new Error(`Invalid YAML: ${errorMessage}`) } } + +export function parseJsonToObject(jsonContent: string): Record { + try { + const parsedObject = JSON.parse(jsonContent) + + // Reject null, undefined, non-objects, or arrays + if (!parsedObject || typeof parsedObject !== "object" || Array.isArray(parsedObject)) { + throw new Error("Invalid JSON: document must be a valid object, not an array or primitive") + } + + // Validate it's a plain object (not Date, Map, Set, etc.) + const isPlainObject = Object.prototype.toString.call(parsedObject) === "[object Object]" + if (!isPlainObject) { + throw new Error( + "Invalid JSON: document must be a plain object compatible with JSON (no custom types like Date, Map, etc.)" + ) + } + + return parsedObject as Record + } catch (err) { + // Wrap any JSON parsing error with "Invalid JSON:" prefix + const errorMessage = (err as Error).message + if (errorMessage.startsWith("Invalid JSON:")) { + throw err + } + throw new Error(`Invalid JSON: ${errorMessage}`) + } +} + +export function parseContentToObject(content: string, format: "yaml" | "json"): Record { + return format === "json" ? parseJsonToObject(content) : parseYamlToObject(content) +} + diff --git a/plugins/kubernetes_ng/app/javascript/widgets/app/routes/clusters/-components/ClusterDetails/DetailsContent.tsx b/plugins/kubernetes_ng/app/javascript/widgets/app/routes/clusters/-components/ClusterDetails/DetailsContent.tsx index 7f0d6e471b..d27b8a896f 100644 --- a/plugins/kubernetes_ng/app/javascript/widgets/app/routes/clusters/-components/ClusterDetails/DetailsContent.tsx +++ b/plugins/kubernetes_ng/app/javascript/widgets/app/routes/clusters/-components/ClusterDetails/DetailsContent.tsx @@ -60,6 +60,7 @@ const DetailsContent = ({ const [isEditingWorkers, setIsEditingWorkers] = useState(false) const [isEditingMaintenance, setIsEditingMaintenance] = useState(false) const [showVersionUpdateDialog, setShowVersionUpdateDialog] = useState(false) + const [yamlFormat, setYamlFormat] = useState<"yaml" | "json">("yaml") const { apiClient } = useRouteContext({ strict: false }) as RouterContext const params = useParams({ from: CLUSTER_DETAIL_ROUTE_ID }) const navigate = useNavigate({ from: CLUSTER_DETAIL_ROUTE_ID }) @@ -451,6 +452,23 @@ const DetailsContent = ({ +
+ + Format: +
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 740c527e2f..7a27cfa444 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -24,6 +24,9 @@ dependencies: '@codemirror/commands': specifier: ^6.10.2 version: 6.10.2 + '@codemirror/lang-json': + specifier: ^6.0.2 + version: 6.0.2 '@codemirror/lang-yaml': specifier: ^6.1.2 version: 6.1.2 @@ -2033,6 +2036,13 @@ packages: '@lezer/common': 1.5.1 dev: false + /@codemirror/lang-json@6.0.2: + resolution: {integrity: sha512-x2OtO+AvwEHrEwR0FyyPtfDUiloG3rnVTSZV1W8UteaLL8/MajQd8DpvUb2YVzC+/T18aSDv0H9mu+xw0EStoQ==} + dependencies: + '@codemirror/language': 6.12.2 + '@lezer/json': 1.0.3 + dev: false + /@codemirror/lang-yaml@6.1.2: resolution: {integrity: sha512-dxrfG8w5Ce/QbT7YID7mWZFKhdhsaTNOYjOkSIMt1qmC4VQnXSDSYVHHHn8k6kJUfIhtLo8t1JJgltlxWdsITw==} dependencies: @@ -2910,6 +2920,14 @@ packages: '@lezer/common': 1.5.1 dev: false + /@lezer/json@1.0.3: + resolution: {integrity: sha512-BP9KzdF9Y35PDpv04r0VeSTKDeox5vVr3efE7eBbx3r4s3oNLfunchejZhjArmeieBH+nVOpgIiBJpEAv8ilqQ==} + dependencies: + '@lezer/common': 1.5.1 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.8 + dev: false + /@lezer/lr@1.4.8: resolution: {integrity: sha512-bPWa0Pgx69ylNlMlPvBPryqeLYQjyJjqPx+Aupm5zydLIF3NE+6MMLT8Yi23Bd9cif9VS00aUebn+6fDIGBcDA==} dependencies: