Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -19,6 +19,7 @@ export interface YamlEditorProps extends Omit<React.HTMLAttributes<HTMLDivElemen
disabled?: boolean
disabledMessage?: string
className?: string
format?: "yaml" | "json"
}

export default function YamlEditor({
Expand All @@ -30,21 +31,23 @@ export default function YamlEditor({
disabled = false,
disabledMessage,
className = "",
format = "yaml",
...props
}: YamlEditorProps) {
const containerRef = useRef<HTMLDivElement>(null)
const editorContainerRef = useRef<HTMLDivElement>(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)

// Manage editor state and actions
const editorState = useYamlEditorState({
resource,
yamlContent,
content,
format,
onSave,
onError,
onEdit,
Expand All @@ -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
Expand All @@ -75,7 +79,7 @@ export default function YamlEditor({
style={{ height: `${TOOLBAR_HEIGHT}px` }}
>
<div className="tw-text-sm tw-text-theme-text-secondary">
{editorState.isEditable ? "Edit Mode" : "Read Mode"}
{editorState.isEditable ? `Edit Mode (${format.toUpperCase()})` : `Read Mode (${format.toUpperCase()})`}
</div>
<div className="tw-ml-auto">
<Stack alignment="center" gap="2">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,15 @@ 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"

// Compartments for dynamic reconfiguration
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)
Expand All @@ -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<boolean>
) {
return [
yaml(),
languageCompartment.of(createLanguageExtension(format)),
syntaxHighlighting(defaultHighlightStyle),
highlightWhitespace(),
highlightActiveLine(),
Expand All @@ -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) => {
Expand Down Expand Up @@ -97,21 +105,23 @@ interface UseCodeMirrorOptions {
containerRef: React.RefObject<HTMLDivElement>
initialContent: string
editorHeight: string
format: "yaml" | "json"
isEditable: boolean
error: string
editedYaml: string
yamlContent: string
editedContent: string
content: string
onDocChange: (value: string) => void
}

export function useCodeMirror({
containerRef,
initialContent,
editorHeight,
format,
isEditable,
error,
editedYaml,
yamlContent,
editedContent,
content,
onDocChange,
}: UseCodeMirrorOptions) {
const editorViewRef = useRef<EditorView | null>(null)
Expand All @@ -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
Expand All @@ -135,6 +146,7 @@ export function useCodeMirror({
doc: initialContentRef.current,
extensions: createEditorExtensions(
initialHeightRef.current,
initialFormatRef.current,
false,
(value) => onDocChangeRef.current(value),
isUpdatingProgrammaticallyRef
Expand Down Expand Up @@ -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(() => {
Expand All @@ -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
}
Original file line number Diff line number Diff line change
@@ -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<string, unknown>): Record<string, unknown> {
const metadata = resource.metadata as Record<string, unknown> | undefined
if (!metadata?.managedFields) {
return resource
}

const filteredMetadata = { ...metadata }
delete filteredMetadata.managedFields
return {
...resource,
metadata: filteredMetadata,
}
}

export function useSerialization(
resource: Record<string, unknown>,
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 }
}
Original file line number Diff line number Diff line change
@@ -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<string, unknown>
yamlContent: string
content: string
format: "yaml" | "json"
onSave: (resource: Record<string, unknown>) => Promise<void>
onError?: (error: Error) => void
onEdit?: () => void
Expand All @@ -13,14 +14,15 @@ interface UseYamlEditorStateOptions {

export function useYamlEditorState({
resource,
yamlContent,
content,
format,
onSave,
onError,
onEdit,
onRefresh,
}: UseYamlEditorStateOptions) {
const [isEditable, setIsEditable] = useState<boolean>(false)
const [editedYaml, setEditedYaml] = useState<string>("")
const [editedContent, setEditedContent] = useState<string>("")
const [showCancelDialog, setShowCancelDialog] = useState<boolean>(false)
const [showVersionConflictDialog, setShowVersionConflictDialog] = useState<boolean>(false)
const [pendingSaveData, setPendingSaveData] = useState<Record<string, unknown> | null>(null)
Expand All @@ -30,28 +32,28 @@ 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
const metadata = resource.metadata as Record<string, unknown> | undefined
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 {
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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,
Expand Down
Loading
Loading