From 99d7f78d99e5e037cc3279fa605b8007ef028293 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Goral?= Date: Mon, 6 Oct 2025 14:49:30 +0200 Subject: [PATCH 01/23] init --- public/locales/en.json | 7 +- src/components/Yaml/YamlPanel.tsx | 2 +- src/components/Yaml/YamlSidePanel.tsx | 24 ++-- src/components/Yaml/YamlViewer.tsx | 5 +- src/components/YamlEditor/YamlDiffEditor.tsx | 1 + src/components/YamlEditor/YamlEditor.tsx | 117 ++++++++++++++++--- src/utils/convertToResourceConfig.spec.ts | 81 +++++++++++++ src/utils/convertToResourceConfig.ts | 56 +++++++++ 8 files changed, 266 insertions(+), 27 deletions(-) create mode 100644 src/utils/convertToResourceConfig.spec.ts create mode 100644 src/utils/convertToResourceConfig.ts diff --git a/public/locales/en.json b/public/locales/en.json index 4d2bf0d3..84ec140c 100644 --- a/public/locales/en.json +++ b/public/locales/en.json @@ -376,11 +376,14 @@ "close": "Close", "back": "Back", "cancel": "Cancel", - "update": "Update" + "update": "Update", + "applyChanges": "Apply changes" }, "yaml": { "YAML": "File", - "showOnlyImportant": "Show only important fields" + "showOnlyImportant": "Show only important fields", + "panelTitle": "YAML", + "editorTitle": "YAML Editor" }, "createMCP": { "dialogTitle": "Create Managed Control Plane", diff --git a/src/components/Yaml/YamlPanel.tsx b/src/components/Yaml/YamlPanel.tsx index 92b04a1b..b72855d8 100644 --- a/src/components/Yaml/YamlPanel.tsx +++ b/src/components/Yaml/YamlPanel.tsx @@ -37,7 +37,7 @@ const YamlPanel: FC = ({ yamlString, filename }) => { )} - + ); }; diff --git a/src/components/Yaml/YamlSidePanel.tsx b/src/components/Yaml/YamlSidePanel.tsx index 6f686bfc..36963f4f 100644 --- a/src/components/Yaml/YamlSidePanel.tsx +++ b/src/components/Yaml/YamlSidePanel.tsx @@ -14,6 +14,7 @@ import { YamlViewer } from './YamlViewer.tsx'; import { useSplitter } from '../Splitter/SplitterContext.tsx'; import { useMemo, useState } from 'react'; import { stringify } from 'yaml'; +import { convertToResourceConfig } from '../../utils/convertToResourceConfig.ts'; import { removeManagedFieldsAndFilterData, Resource } from '../../utils/removeManagedFieldsAndFilterData.ts'; import { useCopyToClipboard } from '../../hooks/useCopyToClipboard.ts'; import styles from './YamlSidePanel.module.css'; @@ -26,13 +27,20 @@ export interface YamlSidePanelProps { } export function YamlSidePanel({ resource, filename }: YamlSidePanelProps) { const [showOnlyImportantData, setShowOnlyImportantData] = useState(true); + const isEdit = true; // Currently always editing YAML (YamlViewer receives isEdit=true) const { closeAside } = useSplitter(); const { t } = useTranslation(); const yamlStringToDisplay = useMemo(() => { + if (isEdit) { + return stringify(convertToResourceConfig(resource)); + } return stringify(removeManagedFieldsAndFilterData(resource, showOnlyImportantData)); }, [resource, showOnlyImportantData]); const yamlStringToCopy = useMemo(() => { + if (isEdit) { + return stringify(convertToResourceConfig(resource)); + } return stringify(removeManagedFieldsAndFilterData(resource, false)); }, [resource]); @@ -55,14 +63,16 @@ export function YamlSidePanel({ resource, filename }: YamlSidePanelProps) { fixed header={ - YAML + {t('yaml.panelTitle')} - setShowOnlyImportantData(!showOnlyImportantData)} - /> + {!isEdit && ( + setShowOnlyImportantData(!showOnlyImportantData)} + /> + )}
- +
); diff --git a/src/components/Yaml/YamlViewer.tsx b/src/components/Yaml/YamlViewer.tsx index 55619f58..58c619eb 100644 --- a/src/components/Yaml/YamlViewer.tsx +++ b/src/components/Yaml/YamlViewer.tsx @@ -7,12 +7,13 @@ import styles from './YamlViewer.module.css'; type YamlViewerProps = { yamlString: string; filename: string; + isEdit?: boolean; }; -export const YamlViewer: FC = ({ yamlString, filename }) => { +export const YamlViewer: FC = ({ yamlString, filename, isEdit = false }) => { return (
- +
); }; diff --git a/src/components/YamlEditor/YamlDiffEditor.tsx b/src/components/YamlEditor/YamlDiffEditor.tsx index 3bd4b43d..82560723 100644 --- a/src/components/YamlEditor/YamlDiffEditor.tsx +++ b/src/components/YamlEditor/YamlDiffEditor.tsx @@ -18,6 +18,7 @@ export const YamlDiffEditor = (props: YamlDiffEditorProps) => { const simplifiedOptions = { // Start from consumer-provided options, then enforce our simplified look ...options, + isKubernetes: true, scrollbar: { ...(options?.scrollbar ?? {}), useShadows: false, diff --git a/src/components/YamlEditor/YamlEditor.tsx b/src/components/YamlEditor/YamlEditor.tsx index be56a6f4..60c75b02 100644 --- a/src/components/YamlEditor/YamlEditor.tsx +++ b/src/components/YamlEditor/YamlEditor.tsx @@ -1,30 +1,117 @@ import { Editor } from '@monaco-editor/react'; import type { ComponentProps } from 'react'; +import { Button, Panel, Toolbar, ToolbarSpacer, Title } from '@ui5/webcomponents-react'; +import { parseDocument } from 'yaml'; +import { useCallback, useEffect, useMemo, useState } from 'react'; import { useTheme } from '../../hooks/useTheme'; import { GITHUB_DARK_DEFAULT, GITHUB_LIGHT_DEFAULT } from '../../lib/monaco.ts'; +import { useTranslation } from 'react-i18next'; +import * as monaco from 'monaco-editor'; // Reuse all props from the underlying Monaco Editor component, except language (we force YAML) -export type YamlEditorProps = Omit, 'language'>; +export type YamlEditorProps = Omit, 'language'> & { + // When true, editor becomes editable and an Apply changes button & validation appear + isEdit?: boolean; +}; -// Simple wrapper that forwards all props to Monaco Editor +// Simple wrapper that forwards all props to Monaco Editor, enhanced with edit/apply capability export const YamlEditor = (props: YamlEditorProps) => { const { isDarkTheme } = useTheme(); - const { theme, options, ...rest } = props; + const { t } = useTranslation(); + const { theme, options, value, defaultValue, onChange, isEdit = false, ...rest } = props; const computedTheme = theme ?? (isDarkTheme ? GITHUB_DARK_DEFAULT : GITHUB_LIGHT_DEFAULT); - const enforcedOptions = { - ...options, - minimap: { enabled: false }, - }; + // Maintain internal state only in edit mode; otherwise rely on provided value (viewer mode) + const [code, setCode] = useState(value?.toString() ?? defaultValue?.toString() ?? ''); + const [errors, setErrors] = useState([]); + const [attemptedApply, setAttemptedApply] = useState(false); + + // Keep internal state in sync when value prop changes in non-edit mode + useEffect(() => { + if (typeof value !== 'undefined') { + setCode(value.toString()); + } + }, [value]); + + const enforcedOptions = useMemo( + () => ({ + ...options, + readOnly: isEdit ? false : (options?.readOnly ?? true), + minimap: { enabled: false }, + isKubernetes: true, + wordWrap: 'on', + scrollBeyondLastLine: false, + }), + [options, isEdit], + ); + + const handleInternalChange = useCallback( + (val: string | undefined) => { + if (isEdit) { + setCode(val ?? ''); + } + onChange?.(val ?? '', undefined as unknown as monaco.editor.IModelContentChangedEvent); + }, + [isEdit, onChange], + ); + + const handleApply = useCallback(() => { + setAttemptedApply(true); + try { + const doc = parseDocument(code); + if (doc.errors && doc.errors.length) { + setErrors(doc.errors.map((e) => e.message)); + return; + } + setErrors([]); + const jsObj = doc.toJS(); + + console.log('Parsed YAML object:', jsObj); + } catch (e: unknown) { + if (e && typeof e === 'object' && 'message' in e) { + // @ts-expect-error narrowing message + setErrors([String(e.message)]); + } else { + setErrors(['Unknown YAML parse error']); + } + } + }, [code]); + + const showErrors = isEdit && attemptedApply && errors.length > 0; return ( - +
+ {isEdit && ( + + {t('yaml.editorTitle')} + + + + )} +
+ +
+ {showErrors && ( + +
    + {errors.map((err, idx) => ( +
  • + {err} +
  • + ))} +
+
+ )} +
); }; diff --git a/src/utils/convertToResourceConfig.spec.ts b/src/utils/convertToResourceConfig.spec.ts new file mode 100644 index 00000000..1eba7868 --- /dev/null +++ b/src/utils/convertToResourceConfig.spec.ts @@ -0,0 +1,81 @@ +import { describe, it, expect } from 'vitest'; +import { convertToResourceConfig } from './convertToResourceConfig'; +import { LAST_APPLIED_CONFIGURATION_ANNOTATION } from '../lib/api/types/shared/keyNames'; +import type { Resource } from './removeManagedFieldsAndFilterData'; + +const baseResource = (): Resource => ({ + apiVersion: 'v1', + kind: 'ConfigMap', + metadata: { + name: 'example', + namespace: 'demo-ns', + labels: { app: 'demo' }, + annotations: { + [LAST_APPLIED_CONFIGURATION_ANNOTATION]: '{"dummy":"config"}', + 'custom/anno': 'keep-me', + }, + managedFields: [{ manager: 'kube-controller' }], + creationTimestamp: '2025-01-01T00:00:00Z', + finalizers: ['protect'], + generation: 3, + resourceVersion: '12345', + uid: 'abcdef', + }, + spec: { foo: 'bar' }, + status: { observedGeneration: 3 }, +}); + +describe('convertToResourceConfig', () => { + it('produces a lean manifest without status & server-only metadata', () => { + const input = baseResource(); + const output = convertToResourceConfig(input); + + // Keep essentials + expect(output.apiVersion).toEqual('v1'); + expect(output.kind).toEqual('ConfigMap'); + expect(output.metadata.name).toEqual('example'); + expect(output.metadata.namespace).toEqual('demo-ns'); + expect(output.metadata.labels).toEqual({ app: 'demo' }); + expect(output.metadata.finalizers).toEqual(['protect']); + expect(output.spec).toEqual({ foo: 'bar' }); + + // Remove unwanted + expect(output.metadata).not.toHaveProperty('managedFields'); + expect(output.metadata).not.toHaveProperty('resourceVersion'); + expect(output.metadata).not.toHaveProperty('uid'); + expect(output.metadata).not.toHaveProperty('generation'); + expect(output.metadata).not.toHaveProperty('creationTimestamp'); + // Removed annotation + expect(output.metadata.annotations?.[LAST_APPLIED_CONFIGURATION_ANNOTATION]).toBeUndefined(); + // Custom annotation kept + expect(output.metadata.annotations?.['custom/anno']).toEqual('keep-me'); + // Status removed + // @ts-expect-error status intentionally absent + expect(output.status).toBeUndefined(); + }); + + it('handles list resources recursively', () => { + const list: Resource = { + apiVersion: 'v1', + kind: 'ConfigMapList', + metadata: { name: 'ignored-list-meta' }, + items: [baseResource(), baseResource()], + }; + + const out = convertToResourceConfig(list); + expect(out.items).toBeDefined(); + expect(out.items?.length).toEqual(2); + out.items?.forEach((item) => { + expect(item.metadata.annotations?.[LAST_APPLIED_CONFIGURATION_ANNOTATION]).toBeUndefined(); + expect(item.metadata.labels).toEqual({ app: 'demo' }); + // @ts-expect-error status intentionally absent + expect(item.status).toBeUndefined(); + }); + }); + + it('returns empty object shape when input is null/undefined', () => { + // @ts-expect-error test invalid input + const out = convertToResourceConfig(null); + expect(out).toBeInstanceOf(Object); + }); +}); diff --git a/src/utils/convertToResourceConfig.ts b/src/utils/convertToResourceConfig.ts new file mode 100644 index 00000000..6fbce751 --- /dev/null +++ b/src/utils/convertToResourceConfig.ts @@ -0,0 +1,56 @@ +import { LAST_APPLIED_CONFIGURATION_ANNOTATION } from '../lib/api/types/shared/keyNames'; +import type { Resource } from './removeManagedFieldsAndFilterData'; + +/** + * Convert an in-cluster Resource (which may contain status and server-populated metadata) + * into a lean manifest suitable for applying with kubectl. + * Rules: + * - Keep: apiVersion, kind, metadata.name, metadata.namespace, metadata.labels, metadata.annotations (except LAST_APPLIED_CONFIGURATION_ANNOTATION), metadata.finalizers, spec. + * - Remove: metadata.managedFields, metadata.resourceVersion, metadata.uid, metadata.generation, metadata.creationTimestamp, + * LAST_APPLIED_CONFIGURATION_ANNOTATION annotation, status. + * - If a List (has items), convert each item recursively. + */ +export const convertToResourceConfig = (resourceObject: Resource | undefined | null): Resource => { + if (!resourceObject) return {} as Resource; + + const base: Resource = { + apiVersion: resourceObject.apiVersion, + kind: resourceObject.kind, + metadata: { + name: resourceObject.metadata?.name || '', + }, + } as Resource; + + if (resourceObject.metadata?.namespace) { + base.metadata.namespace = resourceObject.metadata.namespace; + } + if (resourceObject.metadata?.labels && Object.keys(resourceObject.metadata.labels).length > 0) { + base.metadata.labels = { ...resourceObject.metadata.labels }; + } + if (resourceObject.metadata?.annotations) { + const filtered = { ...resourceObject.metadata.annotations }; + delete filtered[LAST_APPLIED_CONFIGURATION_ANNOTATION]; + // Remove empty annotation object + const keys = Object.keys(filtered).filter((k) => filtered[k] !== undefined && filtered[k] !== ''); + if (keys.length > 0) { + base.metadata.annotations = keys.reduce>((acc, k) => { + const v = filtered[k]; + if (typeof v === 'string') acc[k] = v; + return acc; + }, {}); + } + } + if (resourceObject.metadata?.finalizers && resourceObject.metadata.finalizers.length > 0) { + base.metadata.finalizers = [...resourceObject.metadata.finalizers]; + } + if (resourceObject.spec !== undefined) { + base.spec = resourceObject.spec; + } + + // If list: map items + if (resourceObject.items) { + base.items = resourceObject.items.map((it) => convertToResourceConfig(it)); + } + + return base; +}; From 4c78673fd66cd5a60194293a64b588d8d6a5092f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Goral?= Date: Wed, 8 Oct 2025 09:25:47 +0200 Subject: [PATCH 02/23] fix --- public/locales/en.json | 11 +- .../ControlPlane/ManagedResources.tsx | 221 +++++++++++------- .../ManagedResourcesActionMenu.tsx | 8 +- src/components/Yaml/YamlSidePanel.tsx | 49 +++- src/components/Yaml/YamlViewer.tsx | 11 +- src/components/YamlEditor/YamlEditor.tsx | 53 +++-- 6 files changed, 232 insertions(+), 121 deletions(-) diff --git a/public/locales/en.json b/public/locales/en.json index b2c34bb3..def8fd83 100644 --- a/public/locales/en.json +++ b/public/locales/en.json @@ -37,11 +37,15 @@ "tableHeaderReady": "Ready", "tableHeaderDelete": "Delete", "deleteAction": "Delete resource", + "editAction": "Edit resource", "deleteDialogTitle": "Delete resource", "advancedOptions": "Advanced options", "forceDeletion": "Force deletion", "forceWarningLine": "Force deletion removes finalizers. Related resources may not be deleted and cleanup may be skipped.", "deleteStarted": "Deleting {{resourceName}} initialized", + "patchStarted": "Updating {{resourceName}} initialized", + "patchSuccess": "Updated {{resourceName}}", + "patchError": "Failed to update {{resourceName}}", "actionColumnHeader": " " }, "ProvidersConfig": { @@ -373,7 +377,8 @@ "installError": "Install error", "syncError": "Sync error", "error": "Error", - "notHealthy": "Not healthy" + "notHealthy": "Not healthy", + "notReady": "Not ready" }, "buttons": { "viewResource": "View resource", @@ -391,7 +396,9 @@ "YAML": "File", "showOnlyImportant": "Show only important fields", "panelTitle": "YAML", - "editorTitle": "YAML Editor" + "editorTitle": "YAML Editor", + "applySuccess": "Changes applied successfully", + "applySuccess2": "You can safely close this panel." }, "createMCP": { "dialogTitle": "Create Managed Control Plane", diff --git a/src/components/ControlPlane/ManagedResources.tsx b/src/components/ControlPlane/ManagedResources.tsx index 5b91de95..cbeee317 100644 --- a/src/components/ControlPlane/ManagedResources.tsx +++ b/src/components/ControlPlane/ManagedResources.tsx @@ -15,7 +15,7 @@ import IllustratedError from '../Shared/IllustratedError'; import { resourcesInterval } from '../../lib/shared/constants'; import { YamlViewButton } from '../Yaml/YamlViewButton.tsx'; -import { useMemo, useState } from 'react'; +import { useMemo, useState, useContext, useRef } from 'react'; import StatusFilter from '../Shared/StatusFilter/StatusFilter.tsx'; import { ResourceStatusCell } from '../Shared/ResourceStatusCell.tsx'; import { Resource } from '../../utils/removeManagedFieldsAndFilterData.ts'; @@ -30,15 +30,12 @@ import { PatchResourceForForceDeletionBody, } from '../../lib/api/types/crate/deleteResource'; import { useResourcePluralNames } from '../../hooks/useResourcePluralNames'; - -interface CellData { - cell: { - value: T | null; // null for grouping rows - row: { - original?: ResourceRow; // missing for grouping rows - }; - }; -} +import { useSplitter } from '../Splitter/SplitterContext.tsx'; +import { YamlSidePanel } from '../Yaml/YamlSidePanel.tsx'; +import { fetchApiServerJson } from '../../lib/api/fetch'; +import { ApiConfigContext } from '../Shared/k8s'; +import { ErrorDialog, ErrorDialogHandle } from '../Shared/ErrorMessageBox.tsx'; +import { APIError } from '../../lib/api/error.ts'; type ResourceRow = { kind: string; @@ -56,7 +53,10 @@ type ResourceRow = { export function ManagedResources() { const { t } = useTranslation(); const toast = useToast(); + const { openInAside } = useSplitter(); + const apiConfig = useContext(ApiConfigContext); const [pendingDeleteItem, setPendingDeleteItem] = useState(null); + const errorDialogRef = useRef(null); const { data: managedResources, @@ -81,80 +81,121 @@ export function ManagedResources() { PatchResourceForForceDeletion(apiVersion, pluralKind, resourceName, namespace), ); - const columns: AnalyticalTableColumnDefinition[] = useMemo( - () => [ - { - Header: t('ManagedResources.tableHeaderKind'), - accessor: 'kind', - }, - { - Header: t('ManagedResources.tableHeaderName'), - accessor: 'name', - }, - { - Header: t('ManagedResources.tableHeaderCreated'), - accessor: 'created', - }, - { - Header: t('ManagedResources.tableHeaderSynced'), - accessor: 'synced', - hAlign: 'Center', - width: 125, - Filter: ({ column }) => , - Cell: (cellData: CellData) => - cellData.cell.row.original?.synced != null ? ( - - ) : null, - }, - { - Header: t('ManagedResources.tableHeaderReady'), - accessor: 'ready', - hAlign: 'Center', - width: 125, - Filter: ({ column }) => , - Cell: (cellData: CellData) => - cellData.cell.row.original?.ready != null ? ( - - ) : null, - }, - { - Header: t('yaml.YAML'), - hAlign: 'Center', - width: 75, - accessor: 'yaml', - disableFilters: true, - Cell: (cellData: CellData) => { - return cellData.cell.row.original?.item ? ( - - ) : undefined; + const openEditPanel = (item: ManagedResourceItem) => { + openInAside( + await handleResourcePatch(item, parsed)} + />, + ); + }; + + const handleResourcePatch = async (item: ManagedResourceItem, parsed: unknown): Promise => { + const resourceName = item?.metadata?.name ?? ''; + const apiVersion = item?.apiVersion ?? ''; + const pluralKind = getPluralKind(item.kind); + const namespace = item?.metadata?.namespace; + + toast.show(t('ManagedResources.patchStarted', { resourceName })); + + try { + const basePath = `/apis/${apiVersion}`; + const path = namespace + ? `${basePath}/namespaces/${namespace}/${pluralKind}/${resourceName}` + : `${basePath}/${pluralKind}/${resourceName}`; + + await fetchApiServerJson(path, apiConfig, undefined, 'PATCH', JSON.stringify(parsed)); + toast.show(t('ManagedResources.patchSuccess', { resourceName })); + return true; + } catch (e) { + toast.show(t('ManagedResources.patchError', { resourceName })); + if (e instanceof APIError && errorDialogRef.current) { + errorDialogRef.current.showErrorDialog(`${e.message}: ${JSON.stringify(e.info)}`); + } + console.error('Failed to patch resource', e); + return false; + } + }; + + const columns = useMemo( + () => + [ + { + Header: t('ManagedResources.tableHeaderKind'), + accessor: 'kind', + }, + { + Header: t('ManagedResources.tableHeaderName'), + accessor: 'name', + }, + { + Header: t('ManagedResources.tableHeaderCreated'), + accessor: 'created', }, - }, - { - Header: t('ManagedResources.actionColumnHeader'), - hAlign: 'Center', - width: 60, - disableFilters: true, - Cell: (cellData: CellData) => { - const item = cellData.cell.row.original?.item as ManagedResourceItem; - - return cellData.cell.row.original?.item ? ( - - ) : undefined; + { + Header: t('ManagedResources.tableHeaderSynced'), + accessor: 'synced', + hAlign: 'Center', + width: 125, + Filter: ({ column }: any) => , + Cell: ({ row }: any) => { + const original = row.original as ResourceRow; + return original?.synced != null ? ( + + ) : null; + }, }, - }, - ], + { + Header: t('ManagedResources.tableHeaderReady'), + accessor: 'ready', + hAlign: 'Center', + width: 125, + Filter: ({ column }: any) => , + Cell: ({ row }: any) => { + const original = row.original as ResourceRow; + return original?.ready != null ? ( + + ) : null; + }, + }, + { + Header: t('yaml.YAML'), + hAlign: 'Center', + width: 75, + accessor: 'yaml', + disableFilters: true, + Cell: ({ row }: any) => { + const original = row.original as ResourceRow; + return original?.item ? ( + + ) : undefined; + }, + }, + { + Header: t('ManagedResources.actionColumnHeader'), + hAlign: 'Center', + width: 60, + disableFilters: true, + Cell: ({ row }: any) => { + const original = row.original as ResourceRow; + const item = original?.item as ManagedResourceItem; + return item ? : undefined; + }, + }, + ] as AnalyticalTableColumnDefinition[], [t], ); @@ -192,10 +233,19 @@ export function ManagedResources() { await deleteTrigger(); if (force) { - await patchTrigger(PatchResourceForForceDeletionBody); + try { + await patchTrigger(PatchResourceForForceDeletionBody); + } catch (e) { + if (e instanceof APIError && errorDialogRef.current) { + errorDialogRef.current.showErrorDialog(`${e.message}: ${JSON.stringify(e.info)}`); + } + throw e; // rethrow to outer catch + } + } + } catch (e) { + if (e instanceof APIError && errorDialogRef.current) { + errorDialogRef.current.showErrorDialog(`${e.message}: ${JSON.stringify(e.info)}`); } - } catch (_) { - // Ignore errors - will be handled by the mutation hook } finally { setPendingDeleteItem(null); } @@ -247,6 +297,7 @@ export function ManagedResources() { onClose={() => setPendingDeleteItem(null)} onDeletionConfirmed={handleDeletionConfirmed} /> + )} diff --git a/src/components/ControlPlane/ManagedResourcesActionMenu.tsx b/src/components/ControlPlane/ManagedResourcesActionMenu.tsx index 91d8e7a1..a7f0c264 100644 --- a/src/components/ControlPlane/ManagedResourcesActionMenu.tsx +++ b/src/components/ControlPlane/ManagedResourcesActionMenu.tsx @@ -7,10 +7,11 @@ import type { Ui5CustomEvent, ButtonDomRef } from '@ui5/webcomponents-react'; interface RowActionsMenuProps { item: ManagedResourceItem; - onOpen: (item: ManagedResourceItem) => void; + onOpen: (item: ManagedResourceItem) => void; // delete dialog open + onEdit: (item: ManagedResourceItem) => void; // open YAML editor for patch } -export const RowActionsMenu: FC = ({ item, onOpen }) => { +export const RowActionsMenu: FC = ({ item, onOpen, onEdit }) => { const { t } = useTranslation(); const popoverRef = useRef(null); const [open, setOpen] = useState(false); @@ -33,10 +34,13 @@ export const RowActionsMenu: FC = ({ item, onOpen }) => { const action = element.dataset.action; if (action === 'delete') { onOpen(item); + } else if (action === 'edit') { + onEdit(item); } setOpen(false); }} > + diff --git a/src/components/Yaml/YamlSidePanel.tsx b/src/components/Yaml/YamlSidePanel.tsx index 36963f4f..fc5d5c8d 100644 --- a/src/components/Yaml/YamlSidePanel.tsx +++ b/src/components/Yaml/YamlSidePanel.tsx @@ -7,26 +7,30 @@ import { ToolbarButton, ToolbarSeparator, ToolbarSpacer, + Button, } from '@ui5/webcomponents-react'; - +import IllustrationMessageType from '@ui5/webcomponents-fiori/dist/types/IllustrationMessageType.js'; import { useTranslation } from 'react-i18next'; import { YamlViewer } from './YamlViewer.tsx'; import { useSplitter } from '../Splitter/SplitterContext.tsx'; -import { useMemo, useState } from 'react'; +import { useMemo, useState, useCallback } from 'react'; import { stringify } from 'yaml'; import { convertToResourceConfig } from '../../utils/convertToResourceConfig.ts'; import { removeManagedFieldsAndFilterData, Resource } from '../../utils/removeManagedFieldsAndFilterData.ts'; import { useCopyToClipboard } from '../../hooks/useCopyToClipboard.ts'; import styles from './YamlSidePanel.module.css'; +import { IllustratedBanner } from '../Ui/IllustratedBanner/IllustratedBanner.tsx'; export const SHOW_DOWNLOAD_BUTTON = false; // Download button is hidden now due to stakeholder request export interface YamlSidePanelProps { resource: Resource; filename: string; + onApply?: (parsed: unknown, yaml: string) => void | boolean | Promise; // optional apply handler when in edit mode } -export function YamlSidePanel({ resource, filename }: YamlSidePanelProps) { +export function YamlSidePanel({ resource, filename, onApply }: YamlSidePanelProps) { const [showOnlyImportantData, setShowOnlyImportantData] = useState(true); + const [isSuccess, setIsSuccess] = useState(false); const isEdit = true; // Currently always editing YAML (YamlViewer receives isEdit=true) const { closeAside } = useSplitter(); const { t } = useTranslation(); @@ -36,13 +40,13 @@ export function YamlSidePanel({ resource, filename }: YamlSidePanelProps) { return stringify(convertToResourceConfig(resource)); } return stringify(removeManagedFieldsAndFilterData(resource, showOnlyImportantData)); - }, [resource, showOnlyImportantData]); + }, [resource, showOnlyImportantData, isEdit]); const yamlStringToCopy = useMemo(() => { if (isEdit) { return stringify(convertToResourceConfig(resource)); } return stringify(removeManagedFieldsAndFilterData(resource, false)); - }, [resource]); + }, [resource, isEdit]); const { copyToClipboard } = useCopyToClipboard(); const handleDownloadClick = () => { @@ -57,6 +61,21 @@ export function YamlSidePanel({ resource, filename }: YamlSidePanelProps) { window.URL.revokeObjectURL(url); }; + const handleApplyWrapper = useCallback( + async (parsed: unknown, yaml: string) => { + if (!onApply) return; + try { + const result = await onApply(parsed, yaml); + if (result === true) { + setIsSuccess(true); + } + } catch (_) { + // onApply handles its own error display (toast/dialog) + } + }, + [onApply], + ); + return (
- + {isSuccess ? ( + + + + + ) : ( + + )}
); diff --git a/src/components/Yaml/YamlViewer.tsx b/src/components/Yaml/YamlViewer.tsx index 58c619eb..3f688652 100644 --- a/src/components/Yaml/YamlViewer.tsx +++ b/src/components/Yaml/YamlViewer.tsx @@ -8,12 +8,19 @@ type YamlViewerProps = { yamlString: string; filename: string; isEdit?: boolean; + onApply?: (parsed: unknown, yaml: string) => void | boolean | Promise; }; -export const YamlViewer: FC = ({ yamlString, filename, isEdit = false }) => { +export const YamlViewer: FC = ({ yamlString, filename, isEdit = false, onApply }) => { return (
- +
); }; diff --git a/src/components/YamlEditor/YamlEditor.tsx b/src/components/YamlEditor/YamlEditor.tsx index 60c75b02..44614424 100644 --- a/src/components/YamlEditor/YamlEditor.tsx +++ b/src/components/YamlEditor/YamlEditor.tsx @@ -12,13 +12,14 @@ import * as monaco from 'monaco-editor'; export type YamlEditorProps = Omit, 'language'> & { // When true, editor becomes editable and an Apply changes button & validation appear isEdit?: boolean; + onApply?: (parsed: unknown, yaml: string) => void; // callback when user applies valid YAML }; // Simple wrapper that forwards all props to Monaco Editor, enhanced with edit/apply capability export const YamlEditor = (props: YamlEditorProps) => { const { isDarkTheme } = useTheme(); const { t } = useTranslation(); - const { theme, options, value, defaultValue, onChange, isEdit = false, ...rest } = props; + const { theme, options, value, defaultValue, onChange, isEdit = false, onApply, ...rest } = props; const computedTheme = theme ?? (isDarkTheme ? GITHUB_DARK_DEFAULT : GITHUB_LIGHT_DEFAULT); // Maintain internal state only in edit mode; otherwise rely on provided value (viewer mode) @@ -35,11 +36,10 @@ export const YamlEditor = (props: YamlEditorProps) => { const enforcedOptions = useMemo( () => ({ - ...options, + ...(options as monaco.editor.IStandaloneEditorConstructionOptions), readOnly: isEdit ? false : (options?.readOnly ?? true), minimap: { enabled: false }, - isKubernetes: true, - wordWrap: 'on', + wordWrap: 'on' as const, scrollBeyondLastLine: false, }), [options, isEdit], @@ -56,26 +56,31 @@ export const YamlEditor = (props: YamlEditorProps) => { ); const handleApply = useCallback(() => { - setAttemptedApply(true); - try { - const doc = parseDocument(code); - if (doc.errors && doc.errors.length) { - setErrors(doc.errors.map((e) => e.message)); - return; + const run = async () => { + setAttemptedApply(true); + try { + const doc = parseDocument(code); + if (doc.errors && doc.errors.length) { + setErrors(doc.errors.map((e) => e.message)); + return; + } + setErrors([]); + const jsObj = doc.toJS(); + if (onApply) { + await onApply(jsObj, code); + } else { + console.log('Parsed YAML object:', jsObj); + } + } catch (e: unknown) { + if (e && typeof e === 'object' && 'message' in e) { + setErrors([String((e as any).message)]); + } else { + setErrors(['Unknown YAML parse error']); + } } - setErrors([]); - const jsObj = doc.toJS(); - - console.log('Parsed YAML object:', jsObj); - } catch (e: unknown) { - if (e && typeof e === 'object' && 'message' in e) { - // @ts-expect-error narrowing message - setErrors([String(e.message)]); - } else { - setErrors(['Unknown YAML parse error']); - } - } - }, [code]); + }; + run(); + }, [code, onApply]); const showErrors = isEdit && attemptedApply && errors.length > 0; @@ -95,7 +100,7 @@ export const YamlEditor = (props: YamlEditorProps) => { {...rest} value={isEdit ? code : value} theme={computedTheme} - options={enforcedOptions} + options={enforcedOptions as any} height="100%" language="yaml" onChange={handleInternalChange} From 667bb38b6cf17782fe91a005b23abfba545b3b21 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Goral?= Date: Wed, 8 Oct 2025 10:11:25 +0200 Subject: [PATCH 03/23] fixes --- public/locales/en.json | 6 +- .../SummarizeStep.tsx | 1 + .../YamlDiff.module.css | 6 ++ .../CreateManagedControlPlane/YamlDiff.tsx | 6 +- src/components/Yaml/YamlSidePanel.module.css | 36 +++++++ src/components/Yaml/YamlSidePanel.tsx | 95 +++++++++++++------ 6 files changed, 116 insertions(+), 34 deletions(-) diff --git a/public/locales/en.json b/public/locales/en.json index def8fd83..ddad15ae 100644 --- a/public/locales/en.json +++ b/public/locales/en.json @@ -398,7 +398,11 @@ "panelTitle": "YAML", "editorTitle": "YAML Editor", "applySuccess": "Changes applied successfully", - "applySuccess2": "You can safely close this panel." + "applySuccess2": "Your resource update was submitted.", + "diffConfirmTitle": "Review changes", + "diffConfirmMessage": "Are you sure that you want to apply these changes?", + "diffNo": "No, go back", + "diffYes": "Yes" }, "createMCP": { "dialogTitle": "Create Managed Control Plane", diff --git a/src/components/Wizards/CreateManagedControlPlane/SummarizeStep.tsx b/src/components/Wizards/CreateManagedControlPlane/SummarizeStep.tsx index dbb9a523..ef665630 100644 --- a/src/components/Wizards/CreateManagedControlPlane/SummarizeStep.tsx +++ b/src/components/Wizards/CreateManagedControlPlane/SummarizeStep.tsx @@ -77,6 +77,7 @@ export const SummarizeStep: React.FC = ({ {isEditMode ? ( = ({ originalYaml, modifiedYaml }) => { +export const YamlDiff: FC = ({ originalYaml, modifiedYaml, absolutePosition = false }) => { return ( -
+
); diff --git a/src/components/Yaml/YamlSidePanel.module.css b/src/components/Yaml/YamlSidePanel.module.css index 7112b16d..49ec6095 100644 --- a/src/components/Yaml/YamlSidePanel.module.css +++ b/src/components/Yaml/YamlSidePanel.module.css @@ -8,3 +8,39 @@ height: 100%; width: 100%; } + +.successContainer { + gap: 1rem; + padding: 1rem; + align-items: center; +} + +.reviewContainer { + gap: 1rem; + min-height: 100%; + width: 100%; + position: relative; +} + +.stickyHeader { + position: sticky; + top: 0; + height: 6rem; + padding: 1rem; + padding-bottom: 1.5rem; + z-index: 1; + background: var(--sapBackgroundColor); +} + +.stickyHeaderInner { + padding: 0 1rem; +} + +.diffConfirmMessage { + margin-top: 0.5rem; +} + +.reviewButtons { + gap: 0.5rem; + padding: 0 1rem 1rem; +} diff --git a/src/components/Yaml/YamlSidePanel.tsx b/src/components/Yaml/YamlSidePanel.tsx index fc5d5c8d..6dd9a132 100644 --- a/src/components/Yaml/YamlSidePanel.tsx +++ b/src/components/Yaml/YamlSidePanel.tsx @@ -16,10 +16,11 @@ import { useSplitter } from '../Splitter/SplitterContext.tsx'; import { useMemo, useState, useCallback } from 'react'; import { stringify } from 'yaml'; import { convertToResourceConfig } from '../../utils/convertToResourceConfig.ts'; -import { removeManagedFieldsAndFilterData, Resource } from '../../utils/removeManagedFieldsAndFilterData.ts'; +import { Resource } from '../../utils/removeManagedFieldsAndFilterData.ts'; import { useCopyToClipboard } from '../../hooks/useCopyToClipboard.ts'; import styles from './YamlSidePanel.module.css'; import { IllustratedBanner } from '../Ui/IllustratedBanner/IllustratedBanner.tsx'; +import { YamlDiff } from '../Wizards/CreateManagedControlPlane/YamlDiff.tsx'; export const SHOW_DOWNLOAD_BUTTON = false; // Download button is hidden now due to stakeholder request @@ -30,25 +31,26 @@ export interface YamlSidePanelProps { } export function YamlSidePanel({ resource, filename, onApply }: YamlSidePanelProps) { const [showOnlyImportantData, setShowOnlyImportantData] = useState(true); - const [isSuccess, setIsSuccess] = useState(false); - const isEdit = true; // Currently always editing YAML (YamlViewer receives isEdit=true) + const [mode, setMode] = useState<'edit' | 'review' | 'success'>('edit'); + const [editedYaml, setEditedYaml] = useState(null); + const [parsedObject, setParsedObject] = useState(null); + const isEdit = true; // Always edit mode in this context const { closeAside } = useSplitter(); const { t } = useTranslation(); + const originalYaml = useMemo(() => stringify(convertToResourceConfig(resource)), [resource]); + // yamlStringToDisplay used for editor when in edit mode const yamlStringToDisplay = useMemo(() => { - if (isEdit) { - return stringify(convertToResourceConfig(resource)); + if (mode === 'edit') { + return editedYaml ?? originalYaml; } - return stringify(removeManagedFieldsAndFilterData(resource, showOnlyImportantData)); - }, [resource, showOnlyImportantData, isEdit]); - const yamlStringToCopy = useMemo(() => { - if (isEdit) { - return stringify(convertToResourceConfig(resource)); - } - return stringify(removeManagedFieldsAndFilterData(resource, false)); - }, [resource, isEdit]); + return editedYaml ?? originalYaml; + }, [mode, editedYaml, originalYaml]); + + const yamlStringToCopy = useMemo(() => originalYaml, [originalYaml]); const { copyToClipboard } = useCopyToClipboard(); + const handleDownloadClick = () => { const blob = new Blob([yamlStringToCopy], { type: 'text/yaml' }); const url = window.URL.createObjectURL(blob); @@ -61,20 +63,29 @@ export function YamlSidePanel({ resource, filename, onApply }: YamlSidePanelProp window.URL.revokeObjectURL(url); }; - const handleApplyWrapper = useCallback( - async (parsed: unknown, yaml: string) => { - if (!onApply) return; - try { - const result = await onApply(parsed, yaml); - if (result === true) { - setIsSuccess(true); - } - } catch (_) { - // onApply handles its own error display (toast/dialog) + // First apply from editor: validate -> store edited YAML -> go to review + const handleApplyFromEditor = useCallback(async (parsed: unknown, yaml: string) => { + setParsedObject(parsed); + setEditedYaml(yaml); + setMode('review'); + }, []); + + // User confirms diff -> perform patch + const handleConfirmPatch = useCallback(async () => { + if (!onApply || !editedYaml) return; + try { + const result = await onApply(parsedObject, editedYaml); + if (result === true) { + setMode('success'); } - }, - [onApply], - ); + } catch (_) { + // Stay on review mode; error dialog & toast handled upstream + } + }, [onApply, editedYaml, parsedObject]); + + const handleGoBack = () => { + setMode('edit'); + }; return ( copyToClipboard(yamlStringToCopy)} + onClick={() => copyToClipboard(mode === 'edit' ? yamlStringToDisplay : (editedYaml ?? originalYaml))} /> {SHOW_DOWNLOAD_BUTTON ? (
- {isSuccess ? ( - + {mode === 'success' && ( + - ) : ( + )} + {mode === 'review' && ( + +
+
+ {t('yaml.diffConfirmTitle')} +

{t('yaml.diffConfirmMessage')}

+
+ + + + +
+
+ +
+
+ )} + {mode === 'edit' && ( )}
From cae0b1dd8892bf92c4704a67d20ada145da5476d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Goral?= Date: Wed, 8 Oct 2025 11:22:10 +0200 Subject: [PATCH 04/23] fix --- .../ControlPlane/ManagedResources.tsx | 15 ++++++++------ src/components/Yaml/YamlSidePanel.tsx | 20 ++++--------------- 2 files changed, 13 insertions(+), 22 deletions(-) diff --git a/src/components/ControlPlane/ManagedResources.tsx b/src/components/ControlPlane/ManagedResources.tsx index cbeee317..b1ba635b 100644 --- a/src/components/ControlPlane/ManagedResources.tsx +++ b/src/components/ControlPlane/ManagedResources.tsx @@ -1,4 +1,5 @@ import { useTranslation } from 'react-i18next'; +import { Fragment, useMemo, useState, useContext, useRef } from 'react'; import { AnalyticalTable, AnalyticalTableColumnDefinition, @@ -15,7 +16,6 @@ import IllustratedError from '../Shared/IllustratedError'; import { resourcesInterval } from '../../lib/shared/constants'; import { YamlViewButton } from '../Yaml/YamlViewButton.tsx'; -import { useMemo, useState, useContext, useRef } from 'react'; import StatusFilter from '../Shared/StatusFilter/StatusFilter.tsx'; import { ResourceStatusCell } from '../Shared/ResourceStatusCell.tsx'; import { Resource } from '../../utils/removeManagedFieldsAndFilterData.ts'; @@ -82,12 +82,15 @@ export function ManagedResources() { ); const openEditPanel = (item: ManagedResourceItem) => { + const identityKey = `${item.kind}:${item.metadata.namespace ?? ''}:${item.metadata.name}`; openInAside( - await handleResourcePatch(item, parsed)} - />, + + await handleResourcePatch(item, parsed)} + /> + , ); }; diff --git a/src/components/Yaml/YamlSidePanel.tsx b/src/components/Yaml/YamlSidePanel.tsx index 6dd9a132..4a29a832 100644 --- a/src/components/Yaml/YamlSidePanel.tsx +++ b/src/components/Yaml/YamlSidePanel.tsx @@ -39,16 +39,8 @@ export function YamlSidePanel({ resource, filename, onApply }: YamlSidePanelProp const { t } = useTranslation(); const originalYaml = useMemo(() => stringify(convertToResourceConfig(resource)), [resource]); - // yamlStringToDisplay used for editor when in edit mode - const yamlStringToDisplay = useMemo(() => { - if (mode === 'edit') { - return editedYaml ?? originalYaml; - } - return editedYaml ?? originalYaml; - }, [mode, editedYaml, originalYaml]); - + const yamlStringToDisplay = useMemo(() => editedYaml ?? originalYaml, [editedYaml, originalYaml]); const yamlStringToCopy = useMemo(() => originalYaml, [originalYaml]); - const { copyToClipboard } = useCopyToClipboard(); const handleDownloadClick = () => { @@ -63,14 +55,12 @@ export function YamlSidePanel({ resource, filename, onApply }: YamlSidePanelProp window.URL.revokeObjectURL(url); }; - // First apply from editor: validate -> store edited YAML -> go to review const handleApplyFromEditor = useCallback(async (parsed: unknown, yaml: string) => { setParsedObject(parsed); setEditedYaml(yaml); setMode('review'); }, []); - // User confirms diff -> perform patch const handleConfirmPatch = useCallback(async () => { if (!onApply || !editedYaml) return; try { @@ -79,13 +69,11 @@ export function YamlSidePanel({ resource, filename, onApply }: YamlSidePanelProp setMode('success'); } } catch (_) { - // Stay on review mode; error dialog & toast handled upstream + // upstream handles error messaging } }, [onApply, editedYaml, parsedObject]); - const handleGoBack = () => { - setMode('edit'); - }; + const handleGoBack = () => setMode('edit'); return ( copyToClipboard(mode === 'edit' ? yamlStringToDisplay : (editedYaml ?? originalYaml))} + onClick={() => copyToClipboard(yamlStringToDisplay)} /> {SHOW_DOWNLOAD_BUTTON ? ( Date: Wed, 8 Oct 2025 11:52:38 +0200 Subject: [PATCH 05/23] fixes --- src/components/Yaml/YamlSidePanel.tsx | 15 +++++++++++---- src/components/Yaml/YamlSidePanelWithLoader.tsx | 10 ++++++++-- src/components/Yaml/YamlViewButton.tsx | 2 ++ 3 files changed, 21 insertions(+), 6 deletions(-) diff --git a/src/components/Yaml/YamlSidePanel.tsx b/src/components/Yaml/YamlSidePanel.tsx index 4a29a832..45fcd73f 100644 --- a/src/components/Yaml/YamlSidePanel.tsx +++ b/src/components/Yaml/YamlSidePanel.tsx @@ -16,7 +16,7 @@ import { useSplitter } from '../Splitter/SplitterContext.tsx'; import { useMemo, useState, useCallback } from 'react'; import { stringify } from 'yaml'; import { convertToResourceConfig } from '../../utils/convertToResourceConfig.ts'; -import { Resource } from '../../utils/removeManagedFieldsAndFilterData.ts'; +import { removeManagedFieldsAndFilterData, Resource } from '../../utils/removeManagedFieldsAndFilterData.ts'; import { useCopyToClipboard } from '../../hooks/useCopyToClipboard.ts'; import styles from './YamlSidePanel.module.css'; import { IllustratedBanner } from '../Ui/IllustratedBanner/IllustratedBanner.tsx'; @@ -28,17 +28,24 @@ export interface YamlSidePanelProps { resource: Resource; filename: string; onApply?: (parsed: unknown, yaml: string) => void | boolean | Promise; // optional apply handler when in edit mode + isEdit?: boolean; } -export function YamlSidePanel({ resource, filename, onApply }: YamlSidePanelProps) { +export function YamlSidePanel({ resource, filename, onApply, isEdit }: YamlSidePanelProps) { const [showOnlyImportantData, setShowOnlyImportantData] = useState(true); const [mode, setMode] = useState<'edit' | 'review' | 'success'>('edit'); const [editedYaml, setEditedYaml] = useState(null); const [parsedObject, setParsedObject] = useState(null); - const isEdit = true; // Always edit mode in this context + const { closeAside } = useSplitter(); const { t } = useTranslation(); - const originalYaml = useMemo(() => stringify(convertToResourceConfig(resource)), [resource]); + const originalYaml = useMemo( + () => + isEdit + ? stringify(convertToResourceConfig(resource)) + : stringify(removeManagedFieldsAndFilterData(resource, showOnlyImportantData)), + [isEdit, resource, showOnlyImportantData], + ); const yamlStringToDisplay = useMemo(() => editedYaml ?? originalYaml, [editedYaml, originalYaml]); const yamlStringToCopy = useMemo(() => originalYaml, [originalYaml]); const { copyToClipboard } = useCopyToClipboard(); diff --git a/src/components/Yaml/YamlSidePanelWithLoader.tsx b/src/components/Yaml/YamlSidePanelWithLoader.tsx index 48a3d10a..fb606f32 100644 --- a/src/components/Yaml/YamlSidePanelWithLoader.tsx +++ b/src/components/Yaml/YamlSidePanelWithLoader.tsx @@ -11,8 +11,14 @@ export interface YamlSidePanelWithLoaderProps { workspaceName?: string; resourceType: 'projects' | 'workspaces' | 'managedcontrolplanes'; resourceName: string; + isEdit?: boolean; } -export function YamlSidePanelWithLoader({ workspaceName, resourceType, resourceName }: YamlSidePanelWithLoaderProps) { +export function YamlSidePanelWithLoader({ + workspaceName, + resourceType, + resourceName, + isEdit = false, +}: YamlSidePanelWithLoaderProps) { const { t } = useTranslation(); const { isLoading, data, error } = useApiResource( ResourceObject(workspaceName ?? '', resourceType, resourceName), @@ -25,5 +31,5 @@ export function YamlSidePanelWithLoader({ workspaceName, resourceType, resourceN const filename = `${workspaceName ? `${workspaceName}_` : ''}${resourceType}_${resourceName}`; - return ; + return ; } diff --git a/src/components/Yaml/YamlViewButton.tsx b/src/components/Yaml/YamlViewButton.tsx index 87636dc0..3d0e4edb 100644 --- a/src/components/Yaml/YamlViewButton.tsx +++ b/src/components/Yaml/YamlViewButton.tsx @@ -30,6 +30,7 @@ export function YamlViewButton(props: YamlViewButtonProps) { const { resource } = props; openInAside( , @@ -41,6 +42,7 @@ export function YamlViewButton(props: YamlViewButtonProps) { const { workspaceName, resourceType, resourceName } = props; openInAside( Date: Wed, 8 Oct 2025 11:58:26 +0200 Subject: [PATCH 06/23] Update ManagedResources.tsx --- src/components/ControlPlane/ManagedResources.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/components/ControlPlane/ManagedResources.tsx b/src/components/ControlPlane/ManagedResources.tsx index b1ba635b..6adbc106 100644 --- a/src/components/ControlPlane/ManagedResources.tsx +++ b/src/components/ControlPlane/ManagedResources.tsx @@ -86,6 +86,7 @@ export function ManagedResources() { openInAside( await handleResourcePatch(item, parsed)} From 1b65dde2d78888262b3120a4a4417f6e3d70157b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Goral?= Date: Wed, 8 Oct 2025 17:26:44 +0200 Subject: [PATCH 07/23] refactor --- .../ManagedResourcesActionMenu.tsx | 4 +- src/components/Yaml/YamlSidePanel.tsx | 6 +-- src/components/YamlEditor/YamlDiffEditor.tsx | 6 +-- src/components/YamlEditor/YamlEditor.tsx | 47 ++++++++----------- .../types/crate/createManagedControlPlane.ts | 22 ++++----- 5 files changed, 37 insertions(+), 48 deletions(-) diff --git a/src/components/ControlPlane/ManagedResourcesActionMenu.tsx b/src/components/ControlPlane/ManagedResourcesActionMenu.tsx index a7f0c264..07391645 100644 --- a/src/components/ControlPlane/ManagedResourcesActionMenu.tsx +++ b/src/components/ControlPlane/ManagedResourcesActionMenu.tsx @@ -7,8 +7,8 @@ import type { Ui5CustomEvent, ButtonDomRef } from '@ui5/webcomponents-react'; interface RowActionsMenuProps { item: ManagedResourceItem; - onOpen: (item: ManagedResourceItem) => void; // delete dialog open - onEdit: (item: ManagedResourceItem) => void; // open YAML editor for patch + onOpen: (item: ManagedResourceItem) => void; + onEdit: (item: ManagedResourceItem) => void; } export const RowActionsMenu: FC = ({ item, onOpen, onEdit }) => { diff --git a/src/components/Yaml/YamlSidePanel.tsx b/src/components/Yaml/YamlSidePanel.tsx index 45fcd73f..5795736d 100644 --- a/src/components/Yaml/YamlSidePanel.tsx +++ b/src/components/Yaml/YamlSidePanel.tsx @@ -27,7 +27,7 @@ export const SHOW_DOWNLOAD_BUTTON = false; // Download button is hidden now due export interface YamlSidePanelProps { resource: Resource; filename: string; - onApply?: (parsed: unknown, yaml: string) => void | boolean | Promise; // optional apply handler when in edit mode + onApply?: (parsed: unknown, yaml: string) => void | boolean | Promise; isEdit?: boolean; } export function YamlSidePanel({ resource, filename, onApply, isEdit }: YamlSidePanelProps) { @@ -75,9 +75,7 @@ export function YamlSidePanel({ resource, filename, onApply, isEdit }: YamlSideP if (result === true) { setMode('success'); } - } catch (_) { - // upstream handles error messaging - } + } catch (_) {} }, [onApply, editedYaml, parsedObject]); const handleGoBack = () => setMode('edit'); diff --git a/src/components/YamlEditor/YamlDiffEditor.tsx b/src/components/YamlEditor/YamlDiffEditor.tsx index 82560723..005641f2 100644 --- a/src/components/YamlEditor/YamlDiffEditor.tsx +++ b/src/components/YamlEditor/YamlDiffEditor.tsx @@ -3,7 +3,6 @@ import type { ComponentProps } from 'react'; import { useTheme } from '../../hooks/useTheme'; import { GITHUB_DARK_DEFAULT, GITHUB_LIGHT_DEFAULT } from '../../lib/monaco.ts'; -// Reuse all props from the underlying Monaco DiffEditor component, except language (we force YAML) export type YamlDiffEditorProps = Omit< ComponentProps, 'language' | 'defaultLanguage' | 'originalLanguage' | 'modifiedLanguage' @@ -15,8 +14,7 @@ export const YamlDiffEditor = (props: YamlDiffEditorProps) => { const { theme, options, ...rest } = props; const computedTheme = theme ?? (isDarkTheme ? GITHUB_DARK_DEFAULT : GITHUB_LIGHT_DEFAULT); - const simplifiedOptions = { - // Start from consumer-provided options, then enforce our simplified look + const diffEditorOptions = { ...options, isKubernetes: true, scrollbar: { @@ -38,5 +36,5 @@ export const YamlDiffEditor = (props: YamlDiffEditorProps) => { readOnly: true, }; - return ; + return ; }; diff --git a/src/components/YamlEditor/YamlEditor.tsx b/src/components/YamlEditor/YamlEditor.tsx index 44614424..7bb168ec 100644 --- a/src/components/YamlEditor/YamlEditor.tsx +++ b/src/components/YamlEditor/YamlEditor.tsx @@ -8,29 +8,24 @@ import { GITHUB_DARK_DEFAULT, GITHUB_LIGHT_DEFAULT } from '../../lib/monaco.ts'; import { useTranslation } from 'react-i18next'; import * as monaco from 'monaco-editor'; -// Reuse all props from the underlying Monaco Editor component, except language (we force YAML) export type YamlEditorProps = Omit, 'language'> & { - // When true, editor becomes editable and an Apply changes button & validation appear isEdit?: boolean; - onApply?: (parsed: unknown, yaml: string) => void; // callback when user applies valid YAML + onApply?: (parsed: unknown, yaml: string) => void; }; -// Simple wrapper that forwards all props to Monaco Editor, enhanced with edit/apply capability export const YamlEditor = (props: YamlEditorProps) => { const { isDarkTheme } = useTheme(); const { t } = useTranslation(); const { theme, options, value, defaultValue, onChange, isEdit = false, onApply, ...rest } = props; const computedTheme = theme ?? (isDarkTheme ? GITHUB_DARK_DEFAULT : GITHUB_LIGHT_DEFAULT); - // Maintain internal state only in edit mode; otherwise rely on provided value (viewer mode) - const [code, setCode] = useState(value?.toString() ?? defaultValue?.toString() ?? ''); - const [errors, setErrors] = useState([]); - const [attemptedApply, setAttemptedApply] = useState(false); + const [editorContent, setEditorContent] = useState(value?.toString() ?? defaultValue?.toString() ?? ''); + const [validationErrors, setValidationErrors] = useState([]); + const [applyAttempted, setApplyAttempted] = useState(false); - // Keep internal state in sync when value prop changes in non-edit mode useEffect(() => { if (typeof value !== 'undefined') { - setCode(value.toString()); + setEditorContent(value.toString()); } }, [value]); @@ -45,10 +40,10 @@ export const YamlEditor = (props: YamlEditorProps) => { [options, isEdit], ); - const handleInternalChange = useCallback( + const handleEditorChange = useCallback( (val: string | undefined) => { if (isEdit) { - setCode(val ?? ''); + setEditorContent(val ?? ''); } onChange?.(val ?? '', undefined as unknown as monaco.editor.IModelContentChangedEvent); }, @@ -57,32 +52,30 @@ export const YamlEditor = (props: YamlEditorProps) => { const handleApply = useCallback(() => { const run = async () => { - setAttemptedApply(true); + setApplyAttempted(true); try { - const doc = parseDocument(code); + const doc = parseDocument(editorContent); if (doc.errors && doc.errors.length) { - setErrors(doc.errors.map((e) => e.message)); + setValidationErrors(doc.errors.map((e) => e.message)); return; } - setErrors([]); + setValidationErrors([]); const jsObj = doc.toJS(); if (onApply) { - await onApply(jsObj, code); - } else { - console.log('Parsed YAML object:', jsObj); + await onApply(jsObj, editorContent); } } catch (e: unknown) { if (e && typeof e === 'object' && 'message' in e) { - setErrors([String((e as any).message)]); + setValidationErrors([String((e as any).message)]); } else { - setErrors(['Unknown YAML parse error']); + setValidationErrors(['Unknown YAML parse error']); } } }; run(); - }, [code, onApply]); + }, [editorContent, onApply]); - const showErrors = isEdit && attemptedApply && errors.length > 0; + const showValidationErrors = isEdit && applyAttempted && validationErrors.length > 0; return (
@@ -98,18 +91,18 @@ export const YamlEditor = (props: YamlEditorProps) => {
- {showErrors && ( + {showValidationErrors && (
    - {errors.map((err, idx) => ( + {validationErrors.map((err, idx) => (
  • {err}
  • diff --git a/src/lib/api/types/crate/createManagedControlPlane.ts b/src/lib/api/types/crate/createManagedControlPlane.ts index 315a5fa4..eb8f421d 100644 --- a/src/lib/api/types/crate/createManagedControlPlane.ts +++ b/src/lib/api/types/crate/createManagedControlPlane.ts @@ -57,13 +57,13 @@ export interface CreateManagedControlPlaneType { spec: Spec; } -// rename is used to make creation of MCP working properly -const replaceComponentsName: Record = { +const componentNameMap: Record = { 'sap-btp-service-operator': 'btpServiceOperator', 'external-secrets': 'externalSecretsOperator', }; -export const removeComponents = ['cert-manager']; +export const removedComponents = ['cert-manager']; +export const removeComponents = removedComponents; // backward compatibility alias export const CreateManagedControlPlane = ( name: string, @@ -77,7 +77,7 @@ export const CreateManagedControlPlane = ( }, idpPrefix?: string, ): CreateManagedControlPlaneType => { - const selectedComponentsListObject: Components = + const selectedComponents: Components = optional?.componentsList ?.filter( (component) => @@ -85,8 +85,8 @@ export const CreateManagedControlPlane = ( ) .map((component) => ({ ...component, - name: Object.prototype.hasOwnProperty.call(replaceComponentsName, component.name) - ? replaceComponentsName[component.name] + name: Object.prototype.hasOwnProperty.call(componentNameMap, component.name) + ? componentNameMap[component.name] : component.name, })) .reduce((acc, item) => { @@ -97,17 +97,17 @@ export const CreateManagedControlPlane = ( ({ name, isSelected }) => name === 'crossplane' && isSelected, ); - const providersListObject: Provider[] = + const selectedProviders: Provider[] = optional?.componentsList ?.filter(({ name, isSelected }) => name.includes('provider') && isSelected) .map(({ name, selectedVersion }) => ({ name: name, version: selectedVersion, })) ?? []; - const crossplaneWithProvidersListObject = { + const crossplaneWithProviders = { crossplane: { version: crossplaneComponent?.selectedVersion ?? '', - providers: providersListObject, + providers: selectedProviders, }, }; @@ -128,9 +128,9 @@ export const CreateManagedControlPlane = ( spec: { authentication: { enableSystemIdentityProvider: true }, components: { - ...selectedComponentsListObject, + ...selectedComponents, apiServer: { type: 'GardenerDedicated' }, - ...(crossplaneComponent ? crossplaneWithProvidersListObject : {}), + ...(crossplaneComponent ? crossplaneWithProviders : {}), }, authorization: { roleBindings: From dc27602df82308dc739d40f1c4384a5849a80966 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Goral?= Date: Wed, 8 Oct 2025 18:13:26 +0200 Subject: [PATCH 08/23] fix --- .../ControlPlane/ManagedResources.tsx | 34 ++++++++++++------- src/components/YamlEditor/YamlEditor.tsx | 8 ++--- 2 files changed, 25 insertions(+), 17 deletions(-) diff --git a/src/components/ControlPlane/ManagedResources.tsx b/src/components/ControlPlane/ManagedResources.tsx index 6adbc106..088e0551 100644 --- a/src/components/ControlPlane/ManagedResources.tsx +++ b/src/components/ControlPlane/ManagedResources.tsx @@ -37,6 +37,14 @@ import { ApiConfigContext } from '../Shared/k8s'; import { ErrorDialog, ErrorDialogHandle } from '../Shared/ErrorMessageBox.tsx'; import { APIError } from '../../lib/api/error.ts'; +interface StatusFilterColumn { + filterValue?: string; + setFilter?: (value?: string) => void; +} +interface CellRow { + original: T; +} + type ResourceRow = { kind: string; name: string; @@ -45,7 +53,7 @@ type ResourceRow = { syncedTransitionTime: string; ready: boolean; readyTransitionTime: string; - item: unknown; + item: ManagedResourceItem; conditionReadyMessage: string; conditionSyncedMessage: string; }; @@ -142,9 +150,9 @@ export function ManagedResources() { accessor: 'synced', hAlign: 'Center', width: 125, - Filter: ({ column }: any) => , - Cell: ({ row }: any) => { - const original = row.original as ResourceRow; + Filter: ({ column }: { column: StatusFilterColumn }) => , + Cell: ({ row }: { row: CellRow }) => { + const { original } = row; return original?.synced != null ? ( , - Cell: ({ row }: any) => { - const original = row.original as ResourceRow; + Filter: ({ column }: { column: StatusFilterColumn }) => , + Cell: ({ row }: { row: CellRow }) => { + const { original } = row; return original?.ready != null ? ( { - const original = row.original as ResourceRow; + Cell: ({ row }: { row: CellRow }) => { + const { original } = row; return original?.item ? ( ) : undefined; @@ -193,9 +201,9 @@ export function ManagedResources() { hAlign: 'Center', width: 60, disableFilters: true, - Cell: ({ row }: any) => { - const original = row.original as ResourceRow; - const item = original?.item as ManagedResourceItem; + Cell: ({ row }: { row: CellRow }) => { + const { original } = row; + const item = original?.item; return item ? : undefined; }, }, @@ -243,7 +251,7 @@ export function ManagedResources() { if (e instanceof APIError && errorDialogRef.current) { errorDialogRef.current.showErrorDialog(`${e.message}: ${JSON.stringify(e.info)}`); } - throw e; // rethrow to outer catch + throw e; } } } catch (e) { diff --git a/src/components/YamlEditor/YamlEditor.tsx b/src/components/YamlEditor/YamlEditor.tsx index 7bb168ec..44044f4f 100644 --- a/src/components/YamlEditor/YamlEditor.tsx +++ b/src/components/YamlEditor/YamlEditor.tsx @@ -29,7 +29,7 @@ export const YamlEditor = (props: YamlEditorProps) => { } }, [value]); - const enforcedOptions = useMemo( + const enforcedOptions: monaco.editor.IStandaloneEditorConstructionOptions = useMemo( () => ({ ...(options as monaco.editor.IStandaloneEditorConstructionOptions), readOnly: isEdit ? false : (options?.readOnly ?? true), @@ -65,8 +65,8 @@ export const YamlEditor = (props: YamlEditorProps) => { await onApply(jsObj, editorContent); } } catch (e: unknown) { - if (e && typeof e === 'object' && 'message' in e) { - setValidationErrors([String((e as any).message)]); + if (e instanceof Error) { + setValidationErrors([e.message]); } else { setValidationErrors(['Unknown YAML parse error']); } @@ -93,7 +93,7 @@ export const YamlEditor = (props: YamlEditorProps) => { {...rest} value={isEdit ? editorContent : value} theme={computedTheme} - options={enforcedOptions as any} + options={enforcedOptions} height="100%" language="yaml" onChange={handleEditorChange} From 3548a2f7ebf01987c8d434f39b4b80a4baab2dec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Goral?= Date: Thu, 9 Oct 2025 09:56:04 +0200 Subject: [PATCH 09/23] Update YamlSidePanel.tsx --- src/components/Yaml/YamlSidePanel.tsx | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/src/components/Yaml/YamlSidePanel.tsx b/src/components/Yaml/YamlSidePanel.tsx index 5795736d..5d67c873 100644 --- a/src/components/Yaml/YamlSidePanel.tsx +++ b/src/components/Yaml/YamlSidePanel.tsx @@ -70,12 +70,11 @@ export function YamlSidePanel({ resource, filename, onApply, isEdit }: YamlSideP const handleConfirmPatch = useCallback(async () => { if (!onApply || !editedYaml) return; - try { - const result = await onApply(parsedObject, editedYaml); - if (result === true) { - setMode('success'); - } - } catch (_) {} + + const result = await onApply(parsedObject, editedYaml); + if (result === true) { + setMode('success'); + } }, [onApply, editedYaml, parsedObject]); const handleGoBack = () => setMode('edit'); From 2dbea4b7df9efc251e62c91ba9fd6831e40986b4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Goral?= Date: Thu, 9 Oct 2025 10:32:58 +0200 Subject: [PATCH 10/23] Update ManagedResources.tsx --- src/components/ControlPlane/ManagedResources.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/ControlPlane/ManagedResources.tsx b/src/components/ControlPlane/ManagedResources.tsx index 088e0551..5d643f64 100644 --- a/src/components/ControlPlane/ManagedResources.tsx +++ b/src/components/ControlPlane/ManagedResources.tsx @@ -192,7 +192,7 @@ export function ManagedResources() { Cell: ({ row }: { row: CellRow }) => { const { original } = row; return original?.item ? ( - + ) : undefined; }, }, From 23dba7743a1c084a3af5d41b3f1718dc4916914d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Goral?= Date: Thu, 9 Oct 2025 11:33:09 +0200 Subject: [PATCH 11/23] refactor --- .../ControlPlane/ManagedResources.tsx | 82 ++++++------- .../ProviderConfigsActionMenu.tsx | 44 +++++++ .../ControlPlane/ProvidersConfig.tsx | 109 +++++++++++++----- .../types/crossplane/handleResourcePatch.ts | 44 +++++++ 4 files changed, 206 insertions(+), 73 deletions(-) create mode 100644 src/components/ControlPlane/ProviderConfigsActionMenu.tsx create mode 100644 src/lib/api/types/crossplane/handleResourcePatch.ts diff --git a/src/components/ControlPlane/ManagedResources.tsx b/src/components/ControlPlane/ManagedResources.tsx index 5d643f64..44081fa6 100644 --- a/src/components/ControlPlane/ManagedResources.tsx +++ b/src/components/ControlPlane/ManagedResources.tsx @@ -1,5 +1,5 @@ import { useTranslation } from 'react-i18next'; -import { Fragment, useMemo, useState, useContext, useRef } from 'react'; +import { Fragment, useMemo, useState, useContext, useRef, useCallback } from 'react'; import { AnalyticalTable, AnalyticalTableColumnDefinition, @@ -32,11 +32,13 @@ import { import { useResourcePluralNames } from '../../hooks/useResourcePluralNames'; import { useSplitter } from '../Splitter/SplitterContext.tsx'; import { YamlSidePanel } from '../Yaml/YamlSidePanel.tsx'; -import { fetchApiServerJson } from '../../lib/api/fetch'; + import { ApiConfigContext } from '../Shared/k8s'; import { ErrorDialog, ErrorDialogHandle } from '../Shared/ErrorMessageBox.tsx'; import { APIError } from '../../lib/api/error.ts'; +import { handleResourcePatch } from '../../lib/api/types/crossplane/handleResourcePatch.ts'; + interface StatusFilterColumn { filterValue?: string; setFilter?: (value?: string) => void; @@ -89,46 +91,36 @@ export function ManagedResources() { PatchResourceForForceDeletion(apiVersion, pluralKind, resourceName, namespace), ); - const openEditPanel = (item: ManagedResourceItem) => { - const identityKey = `${item.kind}:${item.metadata.namespace ?? ''}:${item.metadata.name}`; - openInAside( - - await handleResourcePatch(item, parsed)} - /> - , - ); - }; - - const handleResourcePatch = async (item: ManagedResourceItem, parsed: unknown): Promise => { - const resourceName = item?.metadata?.name ?? ''; - const apiVersion = item?.apiVersion ?? ''; - const pluralKind = getPluralKind(item.kind); - const namespace = item?.metadata?.namespace; - - toast.show(t('ManagedResources.patchStarted', { resourceName })); - - try { - const basePath = `/apis/${apiVersion}`; - const path = namespace - ? `${basePath}/namespaces/${namespace}/${pluralKind}/${resourceName}` - : `${basePath}/${pluralKind}/${resourceName}`; + const openDeleteDialog = useCallback((item: ManagedResourceItem) => { + setPendingDeleteItem(item); + }, []); - await fetchApiServerJson(path, apiConfig, undefined, 'PATCH', JSON.stringify(parsed)); - toast.show(t('ManagedResources.patchSuccess', { resourceName })); - return true; - } catch (e) { - toast.show(t('ManagedResources.patchError', { resourceName })); - if (e instanceof APIError && errorDialogRef.current) { - errorDialogRef.current.showErrorDialog(`${e.message}: ${JSON.stringify(e.info)}`); - } - console.error('Failed to patch resource', e); - return false; - } - }; + const openEditPanel = useCallback( + (item: ManagedResourceItem) => { + const identityKey = `${item.kind}:${item.metadata.namespace ?? ''}:${item.metadata.name}`; + openInAside( + + + await handleResourcePatch({ + item, + parsed, + getPluralKind, + apiConfig, + t, + toast, + errorDialogRef, + }) + } + /> + , + ); + }, + [openInAside, getPluralKind, apiConfig, t, toast, errorDialogRef], + ); const columns = useMemo( () => @@ -208,7 +200,7 @@ export function ManagedResources() { }, }, ] as AnalyticalTableColumnDefinition[], - [t], + [t, openEditPanel, openDeleteDialog], ); const rows: ResourceRow[] = @@ -234,10 +226,6 @@ export function ManagedResources() { }), ) ?? []; - const openDeleteDialog = (item: ManagedResourceItem) => { - setPendingDeleteItem(item); - }; - const handleDeletionConfirmed = async (item: ManagedResourceItem, force: boolean) => { toast.show(t('ManagedResources.deleteStarted', { resourceName: item.metadata.name })); @@ -251,7 +239,7 @@ export function ManagedResources() { if (e instanceof APIError && errorDialogRef.current) { errorDialogRef.current.showErrorDialog(`${e.message}: ${JSON.stringify(e.info)}`); } - throw e; + // already handled } } } catch (e) { diff --git a/src/components/ControlPlane/ProviderConfigsActionMenu.tsx b/src/components/ControlPlane/ProviderConfigsActionMenu.tsx new file mode 100644 index 00000000..2e29a0ab --- /dev/null +++ b/src/components/ControlPlane/ProviderConfigsActionMenu.tsx @@ -0,0 +1,44 @@ +import { FC, useRef, useState } from 'react'; +import { Button, Menu, MenuItem, MenuDomRef } from '@ui5/webcomponents-react'; +import { useTranslation } from 'react-i18next'; +import type { ProviderConfigItem } from '../../lib/shared/types'; +import type { ButtonClickEventDetail } from '@ui5/webcomponents/dist/Button.js'; +import type { Ui5CustomEvent, ButtonDomRef } from '@ui5/webcomponents-react'; + +interface ProviderConfigsRowActionsMenuProps { + item: ProviderConfigItem; + onEdit: (item: ProviderConfigItem) => void; +} + +export const ProviderConfigsRowActionsMenu: FC = ({ item, onEdit }) => { + const { t } = useTranslation(); + const popoverRef = useRef(null); + const [open, setOpen] = useState(false); + + const handleOpenerClick = (e: Ui5CustomEvent) => { + if (popoverRef.current && e.currentTarget) { + popoverRef.current.opener = e.currentTarget as unknown as HTMLElement; + setOpen((prev) => !prev); + } + }; + + return ( + <> +