diff --git a/src/main/frontend/app/components/context-editor-footer.tsx b/src/main/frontend/app/components/context-editor-footer.tsx new file mode 100644 index 00000000..e703ce23 --- /dev/null +++ b/src/main/frontend/app/components/context-editor-footer.tsx @@ -0,0 +1,47 @@ +import { type ReactNode } from 'react' +import Button from '~/components/inputs/button' + +type ContextEditorFooterProperties = { + onSave: () => void + onDelete: () => void + saveDisabled?: boolean + deleteDisabled?: boolean + saveLabel?: string + deleteLabel?: string + errorMessage?: string + leadingActions?: ReactNode +} + +export default function ContextEditorFooter({ + onSave, + onDelete, + saveDisabled = false, + deleteDisabled = false, + saveLabel = 'Save & Close', + deleteLabel = 'Delete', + errorMessage, + leadingActions, +}: Readonly) { + return ( +
+
+ + +
+ {leadingActions} + +
+
+ + {errorMessage &&

{errorMessage}

} +
+ ) +} diff --git a/src/main/frontend/app/components/inputs/button.tsx b/src/main/frontend/app/components/inputs/button.tsx index 07b46be9..cf94784f 100644 --- a/src/main/frontend/app/components/inputs/button.tsx +++ b/src/main/frontend/app/components/inputs/button.tsx @@ -1,11 +1,11 @@ import React from 'react' import clsx from 'clsx' -type ButtonVariant = 'default' | 'ghost' | 'primary' | 'destructive' +type ButtonVariant = 'default' | 'ghost' | 'primary' | 'destructive' | 'unstyled' export function buttonClasses(variant: ButtonVariant = 'default', disabled?: boolean, className?: string) { return clsx( - 'rounded-md px-4 py-2', + variant !== 'unstyled' && 'rounded-md px-4 py-2', variant === 'default' && 'text-foreground border-border bg-backdrop border', variant === 'ghost' && 'text-foreground border border-transparent bg-transparent', variant === 'primary' && 'bg-brand font-medium text-white', diff --git a/src/main/frontend/app/components/sidebars-layout/sidebar-layout.tsx b/src/main/frontend/app/components/sidebars-layout/sidebar-layout.tsx index b6a13470..8b63ae19 100644 --- a/src/main/frontend/app/components/sidebars-layout/sidebar-layout.tsx +++ b/src/main/frontend/app/components/sidebars-layout/sidebar-layout.tsx @@ -9,6 +9,7 @@ type SidebarLayoutProperties = { name: string defaultVisible?: VisibilityState windowResizeOnChange?: boolean + hideLeft?: boolean } export default function SidebarLayout({ @@ -16,6 +17,7 @@ export default function SidebarLayout({ name, defaultVisible, windowResizeOnChange, + hideLeft, }: Readonly) { const initializeInstance = useSidebarStore((state) => state.initializeInstance) const setSizes = useSidebarStore((state) => state.setSizes) @@ -39,8 +41,7 @@ export default function SidebarLayout({ if (!allotmentReady || !allotmentRef.current) return if (sizes.length === 0) return - const target = sizes.map((size, i) => (visible[i] ? size : 0)) - + const target = sizes.map((size, index) => (visible[index] ? size : 0)) allotmentRef.current.resize(target) }, [sizes, visible, allotmentReady]) @@ -50,7 +51,7 @@ export default function SidebarLayout({ const saveSizes = (newSizes: number[]) => { const previous = useSidebarStore.getState().getSizes(name) ?? [] - const merged = newSizes.map((size, i) => (size === 0 ? (previous[i] ?? 0) : size)) + const merged = newSizes.map((size, index) => (size === 0 ? (previous[index] ?? 0) : size)) setSizes(name, merged) if (windowResizeOnChange) { globalThis.dispatchEvent(new Event('resize')) @@ -76,7 +77,7 @@ export default function SidebarLayout({ minSize={200} maxSize={500} preferredSize={300} - visible={visible[SidebarSide.LEFT]} + visible={!hideLeft && visible[SidebarSide.LEFT]} className="bg-background flex h-full flex-col" > {childrenArray[SidebarSide.LEFT]} diff --git a/src/main/frontend/app/routes/configurations/adapter-context.tsx b/src/main/frontend/app/routes/configurations/adapter-context.tsx new file mode 100644 index 00000000..e0eab430 --- /dev/null +++ b/src/main/frontend/app/routes/configurations/adapter-context.tsx @@ -0,0 +1,93 @@ +import { useEffect, useState } from 'react' +import Input from '~/components/inputs/input' +import ContextEditorFooter from '~/components/context-editor-footer' +import { deleteAdapter, renameAdapter } from '~/services/adapter-service' + +export type AdapterEditorState = { + configPath: string + adapterName: string + adapterPosition: number +} + +type AdapterContextProperties = { + projectName: string + editor: AdapterEditorState + onSaved: () => void + onDeleted: () => void + onNameChange?: (name: string) => void +} + +export default function AdapterContext({ + projectName, + editor, + onSaved, + onDeleted, + onNameChange, +}: Readonly) { + const [name, setName] = useState(editor.adapterName) + const [isSaving, setIsSaving] = useState(false) + const [errorMessage, setErrorMessage] = useState('') + + useEffect(() => { + onNameChange?.(name) + }, [name, onNameChange]) + + const trimmedName = name.trim() + const canSave = trimmedName !== '' && trimmedName !== editor.adapterName && !isSaving + + const handleSave = async () => { + if (!canSave) return + setIsSaving(true) + setErrorMessage('') + try { + await renameAdapter(projectName, editor.adapterName, trimmedName, editor.configPath) + onSaved() + } catch (error) { + setErrorMessage(error instanceof Error ? error.message : `Failed to rename ${editor.adapterName}`) + setIsSaving(false) + } + } + + const handleDelete = async () => { + setIsSaving(true) + setErrorMessage('') + try { + await deleteAdapter(projectName, editor.adapterName, editor.configPath) + onDeleted() + } catch (error) { + setErrorMessage(error instanceof Error ? error.message : `Failed to delete ${editor.adapterName}`) + setIsSaving(false) + } + } + + return ( +
+
+
{name}
+ +
+
+ + setName(event.target.value)} + /> +
+
+
+ + +
+ ) +} diff --git a/src/main/frontend/app/routes/configurations/adapter-list-item.tsx b/src/main/frontend/app/routes/configurations/adapter-list-item.tsx new file mode 100644 index 00000000..d74f8a47 --- /dev/null +++ b/src/main/frontend/app/routes/configurations/adapter-list-item.tsx @@ -0,0 +1,32 @@ +import RulerCrossPenIcon from '/icons/solar/Ruler Cross Pen.svg?react' +import IconLabelButton from '~/components/inputs/icon-label-button' +import ComponentRow from './component-row' + +type AdapterListItemProperties = { + adapterName: string + adapterPosition: number + onConfigure: () => void + onOpenInStudio: (adapterName: string, adapterPosition: number) => void +} + +export default function AdapterListItem({ + adapterName, + adapterPosition, + onConfigure, + onOpenInStudio, +}: Readonly) { + return ( + } + label="Open in Studio" + onClick={() => onOpenInStudio(adapterName, adapterPosition)} + /> + } + /> + ) +} diff --git a/src/main/frontend/app/routes/configurations/add-non-canvas-component-menu.tsx b/src/main/frontend/app/routes/configurations/add-non-canvas-component-menu.tsx new file mode 100644 index 00000000..66be14ae --- /dev/null +++ b/src/main/frontend/app/routes/configurations/add-non-canvas-component-menu.tsx @@ -0,0 +1,104 @@ +import React, { useMemo, useState, type ChangeEvent } from 'react' +import { createPortal } from 'react-dom' +import { useFFDoc } from '@frankframework/doc-library-react' +import Button from '~/components/inputs/button' +import Search from '~/components/search/search' +import { useFrankConfigXsd } from '~/providers/frankconfig-xsd-provider' +import { getAddableNonCanvasComponentNames } from '~/services/non-canvas-component-service' + +type AddNonCanvasComponentMenuProperties = { + isOpen: boolean + onClose: () => void + onSelect: (tagName: string) => void +} + +export default function AddNonCanvasComponentMenu({ + isOpen, + onClose, + onSelect, +}: Readonly) { + const { elements } = useFFDoc() + const { xsdContent } = useFrankConfigXsd() + const [search, setSearch] = useState('') + const [selected, setSelected] = useState(null) + + const addableNames = useMemo(() => getAddableNonCanvasComponentNames(xsdContent, elements), [xsdContent, elements]) + + const filteredNames = useMemo( + () => addableNames.filter((name) => name.toLowerCase().includes(search.toLowerCase())), + [addableNames, search], + ) + + if (!isOpen) return null + + const clearAndClose = () => { + setSearch('') + setSelected(null) + onClose() + } + + const handleBackdropClick = (event: React.MouseEvent) => { + if (event.target === event.currentTarget) clearAndClose() + } + + const handleSearchChange = (event: ChangeEvent) => { + const value = event.target.value + setSearch(value) + const filtered = addableNames.find((name) => name.toLowerCase().includes(value.toLowerCase())) + setSelected(filtered ?? null) + } + + const handleConfirm = (name?: string) => { + const tagName = name ?? selected + if (!tagName) return + onSelect(tagName) + clearAndClose() + } + + return createPortal( +
+
+ + +

Add non-canvas component

+ + +
+
    + {filteredNames.length > 0 ? ( + filteredNames.map((name) => { + const isSelected = selected === name + return ( +
  • setSelected(name)} + onDoubleClick={() => handleConfirm(name)} + className={`cursor-pointer px-3 py-2 ${ + isSelected ? 'bg-foreground-active text-background' : 'hover:bg-hover' + }`} + > + {name} +
  • + ) + }) + ) : ( +
  • + {addableNames.length === 0 ? 'No addable components found.' : 'No results found.'} +
  • + )} +
+
+ + +
+
, + document.body, + ) +} diff --git a/src/main/frontend/app/routes/configurations/component-list-item.tsx b/src/main/frontend/app/routes/configurations/component-list-item.tsx new file mode 100644 index 00000000..317365b1 --- /dev/null +++ b/src/main/frontend/app/routes/configurations/component-list-item.tsx @@ -0,0 +1,20 @@ +import { type NonCanvasComponent } from '~/services/non-canvas-component-service' +import ComponentRow from './component-row' + +type ComponentListItemProperties = { + component: NonCanvasComponent + onConfigure: () => void +} + +function getComponentLabels(component: NonCanvasComponent): { typeLabel: string | null; primaryLabel: string } { + const primary = component.tagName === 'Include' ? component.attributes.ref : component.name + if (primary && primary.trim()) { + return { typeLabel: component.tagName, primaryLabel: primary } + } + return { typeLabel: null, primaryLabel: component.tagName } +} + +export default function ComponentListItem({ component, onConfigure }: Readonly) { + const { typeLabel, primaryLabel } = getComponentLabels(component) + return +} diff --git a/src/main/frontend/app/routes/configurations/component-row.tsx b/src/main/frontend/app/routes/configurations/component-row.tsx new file mode 100644 index 00000000..f02bc469 --- /dev/null +++ b/src/main/frontend/app/routes/configurations/component-row.tsx @@ -0,0 +1,36 @@ +import { type ReactNode } from 'react' +import Button from '~/components/inputs/button' + +type ComponentRowProperties = { + typeLabel: string | null + primaryLabel: string + onConfigure: () => void + action?: ReactNode +} + +export default function ComponentRow({ + typeLabel, + primaryLabel, + onConfigure, + action, +}: Readonly) { + return ( +
  • + + {action &&
    {action}
    } +
  • + ) +} diff --git a/src/main/frontend/app/routes/configurations/configuration-file-tile.tsx b/src/main/frontend/app/routes/configurations/configuration-file-tile.tsx index f819aa84..2b5a760f 100644 --- a/src/main/frontend/app/routes/configurations/configuration-file-tile.tsx +++ b/src/main/frontend/app/routes/configurations/configuration-file-tile.tsx @@ -1,30 +1,83 @@ -import RulerCrossPenIcon from '/icons/solar/Ruler Cross Pen.svg?react' import TrashBinIcon from '/icons/solar/Trash Bin.svg?react' import CodeIcon from '/icons/solar/Code.svg?react' +import WidgetIcon from '/icons/solar/Widget.svg?react' import { useNavigate } from 'react-router' +import { useRef, useState, type DragEvent } from 'react' import { openInStudio, openInEditor } from '~/actions/navigationActions' import IconButton from '~/components/inputs/icon-button' import IconLabelButton from '~/components/inputs/icon-label-button' import ConfirmDeleteDialog from '~/components/file-structure/confirm-delete-dialog' -import { useState } from 'react' +import LoadingSpinner from '~/components/loading-spinner' import { getBaseName } from '~/utils/path-utils' +import { type NonCanvasComponent } from '~/services/non-canvas-component-service' +import { isRootConfiguration } from './configuration-utils' +import AdapterListItem from './adapter-list-item' +import ComponentListItem from './component-list-item' type ConfigurationFileTileProperties = { filepath: string relativePath: string adapterNames: string[] + nonCanvasComponents: NonCanvasComponent[] + loadingComponents: boolean onDelete: () => Promise + onAddComponent: (configurationPath: string) => void + onEditComponent: (configurationPath: string, component: NonCanvasComponent) => void + onConfigureAdapter: (configurationPath: string, adapterName: string, adapterPosition: number) => void + onDropComponent: (configurationPath: string, tagName: string) => void + draggedTagName?: string | null } export default function ConfigurationFileTile({ filepath, relativePath, adapterNames, + nonCanvasComponents, + loadingComponents, onDelete, + onAddComponent, + onEditComponent, + onConfigureAdapter, + onDropComponent, + draggedTagName = null, }: Readonly) { const [showDeleteDialog, setShowDeleteDialog] = useState(false) + const [isDropTarget, setIsDropTarget] = useState(false) + const dragDepth = useRef(0) const navigate = useNavigate() + const isComponentDrag = draggedTagName !== null + + const handleDragOver = (event: DragEvent) => { + if (!isComponentDrag) return + event.preventDefault() + event.dataTransfer.dropEffect = 'copy' + } + + const handleDragEnter = (event: DragEvent) => { + if (!isComponentDrag) return + event.preventDefault() + dragDepth.current += 1 + setIsDropTarget(true) + } + + const handleDragLeave = () => { + if (!isComponentDrag) return + dragDepth.current -= 1 + if (dragDepth.current <= 0) { + dragDepth.current = 0 + setIsDropTarget(false) + } + } + + const handleDrop = (event: DragEvent) => { + if (!draggedTagName) return + event.preventDefault() + dragDepth.current = 0 + setIsDropTarget(false) + onDropComponent(filepath, draggedTagName) + } + const handleOpenInStudio = (adapterName: string, adapterPosition: number) => { openInStudio(navigate, { adapterName, filepath, adapterPosition }) } @@ -40,45 +93,94 @@ export default function ConfigurationFileTile({ setShowDeleteDialog(false) } + const hasContent = adapterNames.length > 0 || nonCanvasComponents.length > 0 + + let dropZoneClasses = 'border-border' + if (isDropTarget) { + dropZoneClasses = 'border-foreground-active ring-foreground-active border-dashed ring-2' + } else if (isComponentDrag) { + dropZoneClasses = 'border-foreground-active/50 border-dashed' + } + + let componentList + if (loadingComponents && adapterNames.length === 0) { + componentList = ( +
    + +
    + ) + } else if (hasContent) { + componentList = ( +
      + {adapterNames.map((adapterName, adapterPosition) => ( + onConfigureAdapter(filepath, adapterName, adapterPosition)} + onOpenInStudio={handleOpenInStudio} + /> + ))} + {nonCanvasComponents.map((component) => ( + onEditComponent(filepath, component)} + /> + ))} +
    + ) + } else { + componentList =
    No adapters or components found
    + } + return ( -
    -
    -

    - {relativePath} -

    +
    + {isDropTarget && ( +
    + Drop to add component +
    + )} +
    +
    +

    + {relativePath} +

    + {isRootConfiguration(relativePath) && ( + + Root + + )} +
    setShowDeleteDialog(true)}> - +
    - {adapterNames.length > 0 ? ( -
    -

    - {adapterNames.length === 1 ? 'Adapter' : 'Adapters'} -

    -
    -
      - {adapterNames.map((name, index) => ( - - ))} -
    -
    -
    - ) : ( -
    No adapters found
    - )} +
    +

    + Adapters & components +

    +
    {componentList}
    +
    -
    +
    } label="Open in Editor" onClick={handleOpenInEditor} /> + } + label="Add non-canvas component" + onClick={() => onAddComponent(filepath)} + />
    {showDeleteDialog && ( @@ -92,24 +194,3 @@ export default function ConfigurationFileTile({
    ) } - -type AdapterListItemProperties = { - name: string - adapterPosition: number - onOpenInStudio: (name: string, adapterPosition: number) => void -} - -function AdapterListItem({ name, adapterPosition, onOpenInStudio }: Readonly) { - return ( -
  • - - {name} - - } - label="Open in Studio" - onClick={() => onOpenInStudio(name, adapterPosition)} - /> -
  • - ) -} diff --git a/src/main/frontend/app/routes/configurations/configuration-overview.tsx b/src/main/frontend/app/routes/configurations/configuration-overview.tsx index 2caecbc1..b9dc8672 100644 --- a/src/main/frontend/app/routes/configurations/configuration-overview.tsx +++ b/src/main/frontend/app/routes/configurations/configuration-overview.tsx @@ -11,7 +11,20 @@ import type { FileTreeNode } from '~/types/filesystem.types' import { fetchProjectTree } from '~/services/file-tree-service' import Button from '~/components/inputs/button' import Search from '~/components/search/search' +import SidebarLayout from '~/components/sidebars-layout/sidebar-layout' +import SidebarHeader from '~/components/sidebars-layout/sidebar-header' +import SidebarContentClose from '~/components/sidebars-layout/sidebar-content-close' +import { SidebarSide, useSidebarStore } from '~/stores/sidebar-layout-store' +import NonCanvasComponentContext, { type NonCanvasComponentEditorState } from './non-canvas-component-context' +import AdapterContext, { type AdapterEditorState } from './adapter-context' +import NonCanvasComponentPalette from './non-canvas-component-palette' +import AddNonCanvasComponentMenu from './add-non-canvas-component-menu' +import type { NonCanvasComponent } from '~/services/non-canvas-component-service' +import { useNonCanvasComponents } from './use-non-canvas-components' import { relativeTo } from '~/utils/path-utils' +import { isRootConfiguration } from './configuration-utils' + +const SIDEBAR_NAME = 'configuration-overview-v2' type ConfigurationFile = { path: string @@ -44,6 +57,79 @@ export default function ConfigurationOverview() { const [isLoading, setIsLoading] = useState(false) const [searchQuery, setSearchQuery] = useState('') const [debouncedQuery, setDebouncedQuery] = useState(searchQuery) + const [editor, setEditor] = useState(null) + const [adapterEditor, setAdapterEditor] = useState(null) + const [editorName, setEditorName] = useState('') + const [addMenuConfigPath, setAddMenuConfigPath] = useState(null) + const [draggedComponentTagName, setDraggedComponentTagName] = useState(null) + + const setSidebarVisible = useSidebarStore((state) => state.setVisible) + + const openEditor = useCallback( + (state: NonCanvasComponentEditorState) => { + setAdapterEditor(null) + setEditor(state) + setEditorName(state.initialAttributes?.name ?? '') + setSidebarVisible(SIDEBAR_NAME, SidebarSide.RIGHT, true) + }, + [setSidebarVisible], + ) + + const openAdapterEditor = useCallback( + (state: AdapterEditorState) => { + setEditor(null) + setAdapterEditor(state) + setEditorName(state.adapterName) + setSidebarVisible(SIDEBAR_NAME, SidebarSide.RIGHT, true) + }, + [setSidebarVisible], + ) + + const closeEditor = useCallback(() => { + setEditor(null) + setAdapterEditor(null) + setEditorName('') + }, []) + + const handleAddComponent = useCallback((configPath: string) => { + setAddMenuConfigPath(configPath) + }, []) + + const handleSelectComponentType = useCallback( + (tagName: string) => { + if (!addMenuConfigPath) return + openEditor({ mode: 'add', configPath: addMenuConfigPath, tagName }) + setAddMenuConfigPath(null) + }, + [addMenuConfigPath, openEditor], + ) + + const handleEditComponent = useCallback( + (configurationPath: string, component: NonCanvasComponent) => { + openEditor({ + mode: 'edit', + configPath: configurationPath, + tagName: component.tagName, + index: component.index, + initialAttributes: component.attributes, + }) + }, + [openEditor], + ) + + const handleConfigureAdapter = useCallback( + (configurationPath: string, adapterName: string, adapterPosition: number) => { + openAdapterEditor({ configPath: configurationPath, adapterName, adapterPosition }) + }, + [openAdapterEditor], + ) + + const handleDropComponent = useCallback( + (configurationPath: string, tagName: string) => { + openEditor({ mode: 'add', configPath: configurationPath, tagName }) + }, + [openEditor], + ) const loadTree = useCallback( (signal?: AbortSignal) => { @@ -104,27 +190,51 @@ export default function ConfigurationOverview() { return () => clearTimeout(handler) }, [searchQuery]) - const filesWithAdapters = useMemo((): ConfigurationFile[] => { - return configFiles - .filter((file) => file.adapterNames && file.adapterNames.length > 0) - .map((file) => ({ - path: file.path, - relativePath: file.relativePath, - adapterNames: file.adapterNames!, - })) + const allConfigFiles = useMemo((): ConfigurationFile[] => { + const files = configFiles.map((file) => ({ + path: file.path, + relativePath: file.relativePath, + adapterNames: file.adapterNames ?? [], + })) + + return files.toSorted((a, b) => { + const aIsRoot = isRootConfiguration(a.relativePath) + const bIsRoot = isRootConfiguration(b.relativePath) + if (aIsRoot === bIsRoot) return 0 + return aIsRoot ? -1 : 1 + }) }, [configFiles]) const filteredConfigurationFiles = useMemo(() => { - if (!debouncedQuery.trim()) return filesWithAdapters + if (!debouncedQuery.trim()) return allConfigFiles const query = debouncedQuery.toLowerCase() - return filesWithAdapters.filter((file) => { + return allConfigFiles.filter((file) => { const matchesFile = file.relativePath.toLowerCase().includes(query) const matchesAdapter = file.adapterNames.some((adapter) => adapter.toLowerCase().includes(query)) return matchesFile || matchesAdapter }) - }, [filesWithAdapters, debouncedQuery]) + }, [allConfigFiles, debouncedQuery]) + + const configurationPaths = useMemo(() => allConfigFiles.map((file) => file.path), [allConfigFiles]) + const { componentsByPath, loadingByPath, replaceComponents } = useNonCanvasComponents( + currentConfigurationProject?.name ?? '', + configurationPaths, + ) + + const handleComponentSaved = useCallback( + (configurationPath: string, components: NonCanvasComponent[]) => { + replaceComponents(configurationPath, components) + closeEditor() + }, + [replaceComponents, closeEditor], + ) + + const handleAdapterChanged = useCallback(() => { + closeEditor() + loadTree() + }, [closeEditor, loadTree]) if (!currentConfigurationProject) { return ( @@ -145,40 +255,123 @@ export default function ConfigurationOverview() { ) } + let sidebarTitle = 'Components' + if (adapterEditor) { + sidebarTitle = editorName ? `Adapter · ${editorName}` : 'Adapter' + } else if (editor) { + if (editorName) { + sidebarTitle = `${editor.tagName} · ${editorName}` + } else { + sidebarTitle = editor.mode === 'add' ? `Add ${editor.tagName}` : editor.tagName + } + } + + let sidebarContent + if (adapterEditor) { + sidebarContent = ( + + ) + } else if (editor) { + sidebarContent = ( + handleComponentSaved(editor.configPath, components)} + onClose={closeEditor} + onNameChange={setEditorName} + /> + ) + } else { + sidebarContent = ( + setDraggedComponentTagName(null)} + /> + ) + } + return ( -
    -
    navigate('/')}> - -

    Switch configuration

    -
    +
    + + {/* Left slot is intentionally unused; hideLeft keeps its pane hidden. */} + <> -

    Configuration Overview

    -
    -

    - Configuration files within {currentConfigurationProject.name} -

    - -
    +
    +
    +
    navigate('/')} + > + + Switch configuration +
    + +
    -
    - {filteredConfigurationFiles.map((file) => ( - handleDelete(file.path)} - /> - ))} - - setShowModal(true)} /> -
    - setShowModal(false)} - onSuccess={handleConfigAdded} - currentConfiguration={currentConfigurationProject} - configurationsDirPath={tree?.path ?? ''} +
    +
    +

    Configuration Overview

    +

    + Configuration files within{' '} + {currentConfigurationProject.name} +

    +
    +
    + +
    +
    +
    + +
    +
    + {filteredConfigurationFiles.map((file) => ( + handleDelete(file.path)} + onAddComponent={handleAddComponent} + onEditComponent={handleEditComponent} + onConfigureAdapter={handleConfigureAdapter} + onDropComponent={handleDropComponent} + /> + ))} + + setShowModal(true)} /> +
    + + setShowModal(false)} + onSuccess={handleConfigAdded} + currentConfiguration={currentConfigurationProject} + configurationsDirPath={tree?.path ?? ''} + /> +
    +
    + + <> + + {sidebarContent} + + + + setAddMenuConfigPath(null)} + onSelect={handleSelectComponentType} />
    ) diff --git a/src/main/frontend/app/routes/configurations/configuration-utils.ts b/src/main/frontend/app/routes/configurations/configuration-utils.ts new file mode 100644 index 00000000..37357775 --- /dev/null +++ b/src/main/frontend/app/routes/configurations/configuration-utils.ts @@ -0,0 +1,7 @@ +import { getBaseName } from '~/utils/path-utils' + +const ROOT_CONFIGURATION_FILENAME = 'configuration.xml' + +export function isRootConfiguration(relativePath: string): boolean { + return getBaseName(relativePath).toLowerCase() === ROOT_CONFIGURATION_FILENAME +} diff --git a/src/main/frontend/app/routes/configurations/non-canvas-component-context.tsx b/src/main/frontend/app/routes/configurations/non-canvas-component-context.tsx new file mode 100644 index 00000000..5555d701 --- /dev/null +++ b/src/main/frontend/app/routes/configurations/non-canvas-component-context.tsx @@ -0,0 +1,237 @@ +import { useCallback, useEffect, useMemo, useState } from 'react' +import { useFFDoc } from '@frankframework/doc-library-react' +import { + flattenInheritedParentElementProperties, + getInheritedProperties, + type Attribute, +} from '@frankframework/doc-library-core' +import Button from '~/components/inputs/button' +import ContextInput from '~/routes/studio/context/context-input' +import ContextEditorFooter from '~/components/context-editor-footer' +import LoadingSpinner from '~/components/loading-spinner' +import { + addNonCanvasComponent, + deleteNonCanvasComponent, + updateNonCanvasComponent, + type NonCanvasComponent, +} from '~/services/non-canvas-component-service' + +export type NonCanvasComponentEditorState = { + mode: 'add' | 'edit' + configPath: string + tagName: string + index?: number + initialAttributes?: Record +} + +type NonCanvasComponentContextProperties = { + projectName: string + editor: NonCanvasComponentEditorState + onSaved: (components: NonCanvasComponent[]) => void + onClose: () => void + onNameChange?: (name: string) => void +} + +const EMPTY_ATTRIBUTE: Attribute = {} + +export default function NonCanvasComponentContext({ + projectName, + editor, + onSaved, + onClose, + onNameChange, +}: Readonly) { + const { elements, ffDoc, isLoading } = useFFDoc() + const { mode, configPath, tagName, index, initialAttributes } = editor + + const [inputValues, setInputValues] = useState>({}) + const [initiallyFilledKeys, setInitiallyFilledKeys] = useState>(new Set()) + const [showAll, setShowAll] = useState(false) + const [isSaving, setIsSaving] = useState(false) + const [errorMessage, setErrorMessage] = useState('') + + const attributeDefinitions = useMemo>(() => { + const element = elements?.[tagName] + if (!element) return {} + + const directAttributes = element.attributes ?? {} + if (!ffDoc) return directAttributes + + const inherited = getInheritedProperties(element, ffDoc.elements, ffDoc.enums) + return { + ...flattenInheritedParentElementProperties(inherited.attributesOptional), + ...flattenInheritedParentElementProperties(inherited.attributesRequired), + ...directAttributes, + } + }, [elements, ffDoc, tagName]) + + const fieldKeys = useMemo(() => { + const keys = new Set(Object.keys(attributeDefinitions)) + for (const key of Object.keys(initialAttributes ?? {})) keys.add(key) + return [...keys] + }, [attributeDefinitions, initialAttributes]) + + useEffect(() => { + const values: Record = {} + for (const key of fieldKeys) values[key] = initialAttributes?.[key] ?? '' + setInputValues(values) + setInitiallyFilledKeys( + new Set( + Object.entries(values) + .filter(([, value]) => value.trim()) + .map(([key]) => key), + ), + ) + setShowAll(false) + setErrorMessage('') + }, [fieldKeys, initialAttributes, mode, configPath, tagName, index]) + + const canSave = useMemo(() => { + const mandatoryFilled = fieldKeys.every((key) => { + if (!attributeDefinitions[key]?.mandatory) return true + return (inputValues[key] ?? '').trim() !== '' + }) + + const integersValid = fieldKeys.every((key) => { + if (attributeDefinitions[key]?.type !== 'int') return true + const value = (inputValues[key] ?? '').trim() + return value === '' || /^\d+$/.test(value) + }) + + return mandatoryFilled && integersValid + }, [fieldKeys, attributeDefinitions, inputValues]) + + const componentName = inputValues['name']?.trim() ?? '' + + useEffect(() => { + onNameChange?.(componentName) + }, [componentName, onNameChange]) + + const makeEnumOptions = useCallback( + (attribute: Attribute) => { + if (attribute.enum && ffDoc?.enums?.[attribute.enum]) { + return Object.keys(ffDoc.enums[attribute.enum]).reduce( + (result, key) => ({ ...result, [key]: key }), + {} as Record, + ) + } + return + }, + [ffDoc], + ) + + const { baseKeys, restKeys } = useMemo(() => { + const mandatory: string[] = [] + const filled: string[] = [] + const rest: string[] = [] + for (const key of fieldKeys) { + if (key === 'name') continue // always shown first, handled below + if (attributeDefinitions[key]?.mandatory) mandatory.push(key) + else if (initiallyFilledKeys.has(key)) filled.push(key) + else rest.push(key) + } + + const leading = fieldKeys.includes('name') ? ['name'] : [] + return { baseKeys: [...leading, ...mandatory, ...filled], restKeys: rest } + }, [fieldKeys, attributeDefinitions, initiallyFilledKeys]) + + const orderedKeys = showAll ? [...baseKeys, ...restKeys] : baseKeys + + const handleChange = (key: string, value: string) => { + setInputValues((previous) => ({ ...previous, [key]: value })) + } + + const resolveFilledAttributes = useCallback(() => { + const result: Record = {} + for (const key of fieldKeys) { + const value = (inputValues[key] ?? '').trim() + if (value) result[key] = value + } + return result + }, [fieldKeys, inputValues]) + + const handleSave = async () => { + if (!canSave || isSaving) return + setIsSaving(true) + setErrorMessage('') + try { + const attributes = resolveFilledAttributes() + const pendingSave = + mode === 'add' + ? addNonCanvasComponent(projectName, configPath, tagName, attributes) + : updateNonCanvasComponent(projectName, configPath, tagName, index ?? 0, attributes) + const updatedComponents = await pendingSave + onSaved(updatedComponents) + } catch (error) { + setErrorMessage(error instanceof Error ? error.message : `Failed to save ${tagName}`) + } finally { + setIsSaving(false) + } + } + + const handleDelete = async () => { + setIsSaving(true) + setErrorMessage('') + + try { + const updatedComponents = await deleteNonCanvasComponent(projectName, configPath, tagName, index ?? 0) + onSaved(updatedComponents) + } catch (error) { + setErrorMessage(error instanceof Error ? error.message : `Failed to delete ${tagName}`) + setIsSaving(false) + } + } + + if (isLoading || !elements) { + return ( +
    + +
    + ) + } + + return ( +
    +
    +
    {tagName}
    + {componentName &&

    {componentName}

    } + +
    + {fieldKeys.length === 0 && ( +

    This component has no configurable attributes.

    + )} + + {orderedKeys.map((key) => ( + handleChange(key, value)} + label={key} + attribute={attributeDefinitions[key] ?? EMPTY_ATTRIBUTE} + enumOptions={makeEnumOptions(attributeDefinitions[key] ?? EMPTY_ATTRIBUTE)} + elements={elements ?? null} + /> + ))} + + {restKeys.length > 0 && ( +
    + +
    + )} +
    +
    + + +
    + ) +} diff --git a/src/main/frontend/app/routes/configurations/non-canvas-component-palette.tsx b/src/main/frontend/app/routes/configurations/non-canvas-component-palette.tsx new file mode 100644 index 00000000..e6cd1104 --- /dev/null +++ b/src/main/frontend/app/routes/configurations/non-canvas-component-palette.tsx @@ -0,0 +1,73 @@ +import { useMemo, useState, type ChangeEvent, type DragEvent } from 'react' +import { useFFDoc } from '@frankframework/doc-library-react' +import Search from '~/components/search/search' +import LoadingSpinner from '~/components/loading-spinner' +import { useFrankConfigXsd } from '~/providers/frankconfig-xsd-provider' +import { getElementTypeFromName } from '~/routes/studio/node-translator-module' +import { getAddableNonCanvasComponentNames } from '~/services/non-canvas-component-service' + +/** + * Palette of non-canvas components that can be dragged onto a configuration tile to add them. + */ +export default function NonCanvasComponentPalette({ + onDragStart, + onDragEnd, +}: { + onDragStart?: (tagName: string) => void + onDragEnd?: () => void +}) { + const { elements, isLoading } = useFFDoc() + const { xsdContent } = useFrankConfigXsd() + const [search, setSearch] = useState('') + + const addableNames = useMemo(() => getAddableNonCanvasComponentNames(xsdContent, elements), [xsdContent, elements]) + + const filteredNames = useMemo( + () => addableNames.filter((name) => name.toLowerCase().includes(search.toLowerCase())), + [addableNames, search], + ) + + const handleSearchChange = (event: ChangeEvent) => setSearch(event.target.value) + + const handleDragStart = (tagName: string) => (event: DragEvent) => { + event.dataTransfer.setData('text/plain', tagName) + event.dataTransfer.effectAllowed = 'copy' + onDragStart?.(tagName) + } + + const handleDragEnd = () => onDragEnd?.() + + return ( +
    + + + {isLoading || !elements || !xsdContent ? ( +
    + +
    + ) : ( +
      + {filteredNames.length === 0 ? ( +
    • + {addableNames.length === 0 ? 'No addable components found.' : 'No results found.'} +
    • + ) : ( + filteredNames.map((name) => ( +
    • + {name} +
    • + )) + )} +
    + )} +
    + ) +} diff --git a/src/main/frontend/app/routes/configurations/use-non-canvas-components.ts b/src/main/frontend/app/routes/configurations/use-non-canvas-components.ts new file mode 100644 index 00000000..a4a7fc56 --- /dev/null +++ b/src/main/frontend/app/routes/configurations/use-non-canvas-components.ts @@ -0,0 +1,48 @@ +import { useCallback, useEffect, useState } from 'react' +import { + getNonCanvasComponentsFromConfiguration, + type NonCanvasComponent, +} from '~/services/non-canvas-component-service' + +type NonCanvasComponentsState = { + componentsByPath: Record + loadingByPath: Record + replaceComponents: (configurationPath: string, components: NonCanvasComponent[]) => void +} + +export function useNonCanvasComponents(projectName: string, configurationPaths: string[]): NonCanvasComponentsState { + const [componentsByPath, setComponentsByPath] = useState>({}) + const [loadingByPath, setLoadingByPath] = useState>({}) + const pathsKey = JSON.stringify(configurationPaths) + + useEffect(() => { + const paths: string[] = JSON.parse(pathsKey) + const controller = new AbortController() + + setLoadingByPath(Object.fromEntries(paths.map((path) => [path, true]))) + + for (const path of paths) { + getNonCanvasComponentsFromConfiguration(projectName, path, controller.signal) + .then((components) => { + if (controller.signal.aborted) return + setComponentsByPath((previous) => ({ ...previous, [path]: components })) + }) + .catch(() => { + if (controller.signal.aborted) return + setComponentsByPath((previous) => ({ ...previous, [path]: [] })) + }) + .finally(() => { + if (controller.signal.aborted) return + setLoadingByPath((previous) => ({ ...previous, [path]: false })) + }) + } + + return () => controller.abort() + }, [projectName, pathsKey]) + + const replaceComponents = useCallback((configurationPath: string, components: NonCanvasComponent[]) => { + setComponentsByPath((previous) => ({ ...previous, [configurationPath]: components })) + }, []) + + return { componentsByPath, loadingByPath, replaceComponents } +} diff --git a/src/main/frontend/app/routes/studio/context/node-context.tsx b/src/main/frontend/app/routes/studio/context/node-context.tsx index 6797c4e3..a8ab291d 100644 --- a/src/main/frontend/app/routes/studio/context/node-context.tsx +++ b/src/main/frontend/app/routes/studio/context/node-context.tsx @@ -4,6 +4,7 @@ import { useCallback, useEffect, useRef, useState } from 'react' import { useShortcut } from '~/hooks/use-shortcut' import useFlowStore, { isFrankNode } from '~/stores/flow-store' import Button from '~/components/inputs/button' +import ContextEditorFooter from '~/components/context-editor-footer' import { useShallow } from 'zustand/react/shallow' import ContextInput from './context-input' import { findChildRecursive } from '~/stores/child-utilities' @@ -376,29 +377,17 @@ export default function NodeContext({
    -
    -
    - - -
    - - - -
    -
    - - {!canSave && errorMessage &&

    {errorMessage}

    } -
    + } + /> ) } diff --git a/src/main/frontend/app/services/non-canvas-component-service.ts b/src/main/frontend/app/services/non-canvas-component-service.ts new file mode 100644 index 00000000..aa5bab51 --- /dev/null +++ b/src/main/frontend/app/services/non-canvas-component-service.ts @@ -0,0 +1,106 @@ +import { apiFetch } from '~/utils/api' +import { parseXsd, getFirstLevelElementsForType, getAllowedChildElementsForElement } from '~/utils/xsd-utils' +import type { Elements } from '@frankframework/doc-library-core' + +export type NonCanvasComponent = { + tagName: string + name: string | null + index: number + attributes: Record +} + +const ROOT_TYPE_CANDIDATES = ['ConfigurationType', 'ModuleType', 'Configuration', 'Module'] +const EXCLUDED_NAMES = new Set(['Adapter']) +const NON_EXPANDABLE_NAMES = new Set(['Module', 'Configuration']) +const PARAMETER_COMPONENT_LABEL = 'Parameters' + +function getBaseUrl(projectName: string): string { + return `/projects/${encodeURIComponent(projectName)}/non-canvas-components` +} + +export function getAddableNonCanvasComponentNames(xsdContent: string | null, elements: Elements | null): string[] { + if (!xsdContent || !elements) return [] + + const xsdDocument = parseXsd(xsdContent) + + const hasNameAttribute = (name: string) => Boolean(elements[name]?.attributes?.['name']) + const isParameter = (name: string) => elements[name]?.labels?.['Components'] === PARAMETER_COMPONENT_LABEL + + const addableNames: string[] = [] + const visitedNames = new Set() + + const collect = (name: string) => { + if (EXCLUDED_NAMES.has(name) || visitedNames.has(name)) return + visitedNames.add(name) + + if (!elements[name]) return + + if (hasNameAttribute(name) || NON_EXPANDABLE_NAMES.has(name)) { + addableNames.push(name) + return + } + + const childNames = getAllowedChildElementsForElement(xsdDocument, name).filter( + (childName) => childName !== name && elements[childName] && !isParameter(childName), + ) + + if (childNames.length === 0) { + addableNames.push(name) + return + } + + for (const childName of childNames) collect(childName) + } + + for (const rootType of ROOT_TYPE_CANDIDATES) { + for (const name of getFirstLevelElementsForType(xsdDocument, rootType)) collect(name) + } + + return addableNames.toSorted((first, second) => first.localeCompare(second)) +} + +export async function getNonCanvasComponentsFromConfiguration( + projectName: string, + configurationPath: string, + signal?: AbortSignal, +): Promise { + return apiFetch( + `${getBaseUrl(projectName)}?configurationPath=${encodeURIComponent(configurationPath)}`, + { signal }, + ) +} + +export async function addNonCanvasComponent( + projectName: string, + configurationPath: string, + tagName: string, + attributes: Record, +): Promise { + return apiFetch(getBaseUrl(projectName), { + method: 'POST', + body: JSON.stringify({ configurationPath, tagName, attributes }), + }) +} + +export async function updateNonCanvasComponent( + projectName: string, + configurationPath: string, + tagName: string, + index: number, + attributes: Record, +): Promise { + return apiFetch(getBaseUrl(projectName), { + method: 'PUT', + body: JSON.stringify({ configurationPath, tagName, index, attributes }), + }) +} + +export async function deleteNonCanvasComponent( + projectName: string, + configurationPath: string, + tagName: string, + index: number, +): Promise { + const query = `configurationPath=${encodeURIComponent(configurationPath)}&tagName=${encodeURIComponent(tagName)}&index=${index}` + return apiFetch(`${getBaseUrl(projectName)}?${query}`, { method: 'DELETE' }) +} diff --git a/src/main/java/org/frankframework/flow/noncanvascomponent/NonCanvasComponentController.java b/src/main/java/org/frankframework/flow/noncanvascomponent/NonCanvasComponentController.java new file mode 100644 index 00000000..9d363cce --- /dev/null +++ b/src/main/java/org/frankframework/flow/noncanvascomponent/NonCanvasComponentController.java @@ -0,0 +1,51 @@ +package org.frankframework.flow.noncanvascomponent; + +import java.util.List; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/projects/{projectName}/non-canvas-components") +public class NonCanvasComponentController { + + private final NonCanvasComponentService nonCanvasComponentService; + + public NonCanvasComponentController(NonCanvasComponentService nonCanvasComponentService) { + this.nonCanvasComponentService = nonCanvasComponentService; + } + + @GetMapping + public ResponseEntity> getNonCanvasComponents(@RequestParam String configurationPath) { + return ResponseEntity.ok(nonCanvasComponentService.getNonCanvasComponents(configurationPath)); + } + + @PostMapping + public ResponseEntity> addNonCanvasComponent(@RequestBody NonCanvasComponentCreateDTO request) { + List components = + nonCanvasComponentService.addNonCanvasComponent(request.configurationPath(), request.tagName(), request.attributes()); + return ResponseEntity.ok(components); + } + + @PutMapping + public ResponseEntity> updateNonCanvasComponent(@RequestBody NonCanvasComponentUpdateDTO request) { + List components = nonCanvasComponentService.updateNonCanvasComponent( + request.configurationPath(), request.tagName(), request.index(), request.attributes()); + return ResponseEntity.ok(components); + } + + @DeleteMapping + public ResponseEntity> deleteNonCanvasComponent( + @RequestParam String configurationPath, + @RequestParam String tagName, + @RequestParam int index) { + List components = nonCanvasComponentService.deleteNonCanvasComponent(configurationPath, tagName, index); + return ResponseEntity.ok(components); + } +} diff --git a/src/main/java/org/frankframework/flow/noncanvascomponent/NonCanvasComponentCreateDTO.java b/src/main/java/org/frankframework/flow/noncanvascomponent/NonCanvasComponentCreateDTO.java new file mode 100644 index 00000000..a16bd4a6 --- /dev/null +++ b/src/main/java/org/frankframework/flow/noncanvascomponent/NonCanvasComponentCreateDTO.java @@ -0,0 +1,5 @@ +package org.frankframework.flow.noncanvascomponent; + +import java.util.Map; + +public record NonCanvasComponentCreateDTO(String configurationPath, String tagName, Map attributes) {} diff --git a/src/main/java/org/frankframework/flow/noncanvascomponent/NonCanvasComponentDTO.java b/src/main/java/org/frankframework/flow/noncanvascomponent/NonCanvasComponentDTO.java new file mode 100644 index 00000000..a766c014 --- /dev/null +++ b/src/main/java/org/frankframework/flow/noncanvascomponent/NonCanvasComponentDTO.java @@ -0,0 +1,5 @@ +package org.frankframework.flow.noncanvascomponent; + +import java.util.Map; + +public record NonCanvasComponentDTO(String tagName, String name, int index, Map attributes) {} diff --git a/src/main/java/org/frankframework/flow/noncanvascomponent/NonCanvasComponentService.java b/src/main/java/org/frankframework/flow/noncanvascomponent/NonCanvasComponentService.java new file mode 100644 index 00000000..af4bd151 --- /dev/null +++ b/src/main/java/org/frankframework/flow/noncanvascomponent/NonCanvasComponentService.java @@ -0,0 +1,135 @@ +package org.frankframework.flow.noncanvascomponent; + +import java.io.IOException; +import java.io.StringReader; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import javax.xml.parsers.ParserConfigurationException; +import javax.xml.transform.TransformerException; +import lombok.extern.log4j.Log4j2; +import org.frankframework.flow.exception.ApiException; +import org.frankframework.flow.filesystem.FileSystemStorage; +import org.frankframework.flow.utility.XmlConfigurationUtils; +import org.frankframework.flow.utility.XmlNonCanvasComponentUtils; +import org.frankframework.flow.utility.XmlSecurityUtils; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.w3c.dom.Document; +import org.w3c.dom.Element; +import org.xml.sax.InputSource; +import org.xml.sax.SAXException; + +@Log4j2 +@Service +public class NonCanvasComponentService { + + private static final String NAME_ATTRIBUTE = "name"; + + private final FileSystemStorage fileSystemStorage; + + public NonCanvasComponentService(FileSystemStorage fileSystemStorage) { + this.fileSystemStorage = fileSystemStorage; + } + + public List getNonCanvasComponents(String configurationPath) { + Document configurationDocument = readConfigurationDocument(configurationPath); + return toDataTransferObjects(configurationDocument); + } + + public List addNonCanvasComponent(String configurationPath, String tagName, Map attributes) { + validateTagName(tagName); + Document configurationDocument = readConfigurationDocument(configurationPath); + XmlNonCanvasComponentUtils.addNonCanvasComponent(configurationDocument, tagName, attributes); + return writeAndList(configurationPath, configurationDocument); + } + + public List updateNonCanvasComponent(String configurationPath, String tagName, int index, Map attributes) { + validateTagName(tagName); + Document configurationDocument = readConfigurationDocument(configurationPath); + boolean updated = XmlNonCanvasComponentUtils.updateNonCanvasComponent(configurationDocument, tagName, index, attributes); + + if (!updated) { + throw new ApiException("Non-canvas component not found: " + tagName, HttpStatus.NOT_FOUND); + } + + return writeAndList(configurationPath, configurationDocument); + } + + public List deleteNonCanvasComponent(String configurationPath, String tagName, int index) { + validateTagName(tagName); + Document configurationDocument = readConfigurationDocument(configurationPath); + boolean removed = XmlNonCanvasComponentUtils.removeNonCanvasComponent(configurationDocument, tagName, index); + + if (!removed) { + throw new ApiException("Non-canvas component not found: " + tagName, HttpStatus.NOT_FOUND); + } + + return writeAndList(configurationPath, configurationDocument); + } + + private List writeAndList(String configurationPath, Document configurationDocument) { + writeConfigurationDocument(configurationPath, configurationDocument); + return toDataTransferObjects(configurationDocument); + } + + private List toDataTransferObjects(Document configurationDocument) { + List components = new ArrayList<>(); + Map occurrenceByTagName = new HashMap<>(); + + for (Element element : XmlNonCanvasComponentUtils.getNonCanvasComponents(configurationDocument)) { + String tagName = element.getTagName(); + int index = occurrenceByTagName.merge(tagName, 1, Integer::sum) - 1; + Map attributes = XmlNonCanvasComponentUtils.getAttributes(element); + String name = attributes.get(NAME_ATTRIBUTE); + components.add(new NonCanvasComponentDTO(tagName, name, index, attributes)); + } + + return components; + } + + private Document readConfigurationDocument(String configurationPath) { + Path absolutePath = resolveExistingConfiguration(configurationPath); + + try { + String content = fileSystemStorage.readFile(absolutePath.toString()); + String repairedContent = XmlConfigurationUtils.repairFlowNamespace(content); + return XmlSecurityUtils.createSecureDocumentBuilder().parse(new InputSource(new StringReader(repairedContent))); + } catch (IOException | ParserConfigurationException | SAXException exception) { + throw new ApiException("Failed to read configuration: " + exception.getMessage(), HttpStatus.INTERNAL_SERVER_ERROR); + } + } + + private void writeConfigurationDocument(String configurationPath, Document configurationDocument) { + Path absolutePath = resolveExistingConfiguration(configurationPath); + + try { + String updatedContent = XmlConfigurationUtils.convertNodeToString(configurationDocument); + fileSystemStorage.writeFile(absolutePath.toString(), updatedContent); + } catch (TransformerException | IOException exception) { + throw new ApiException("Failed to write configuration: " + exception.getMessage(), HttpStatus.INTERNAL_SERVER_ERROR); + } + } + + private Path resolveExistingConfiguration(String configurationPath) { + if (configurationPath == null || configurationPath.isBlank()) { + throw new ApiException("Configuration path must not be empty", HttpStatus.BAD_REQUEST); + } + + Path absolutePath = fileSystemStorage.toAbsolutePath(configurationPath); + if (!Files.exists(absolutePath) || Files.isDirectory(absolutePath)) { + throw new ApiException("Configuration file not found: " + configurationPath, HttpStatus.NOT_FOUND); + } + + return absolutePath; + } + + private void validateTagName(String tagName) { + if (tagName == null || tagName.isBlank()) { + throw new ApiException("Component type must not be empty", HttpStatus.BAD_REQUEST); + } + } +} diff --git a/src/main/java/org/frankframework/flow/noncanvascomponent/NonCanvasComponentUpdateDTO.java b/src/main/java/org/frankframework/flow/noncanvascomponent/NonCanvasComponentUpdateDTO.java new file mode 100644 index 00000000..75f1c0ac --- /dev/null +++ b/src/main/java/org/frankframework/flow/noncanvascomponent/NonCanvasComponentUpdateDTO.java @@ -0,0 +1,5 @@ +package org.frankframework.flow.noncanvascomponent; + +import java.util.Map; + +public record NonCanvasComponentUpdateDTO(String configurationPath, String tagName, int index, Map attributes) {} diff --git a/src/main/java/org/frankframework/flow/utility/XmlNonCanvasComponentUtils.java b/src/main/java/org/frankframework/flow/utility/XmlNonCanvasComponentUtils.java new file mode 100644 index 00000000..15ec8477 --- /dev/null +++ b/src/main/java/org/frankframework/flow/utility/XmlNonCanvasComponentUtils.java @@ -0,0 +1,161 @@ +package org.frankframework.flow.utility; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import lombok.experimental.UtilityClass; +import org.w3c.dom.Document; +import org.w3c.dom.Element; +import org.w3c.dom.NamedNodeMap; +import org.w3c.dom.Node; +import org.w3c.dom.NodeList; + +@UtilityClass +public class XmlNonCanvasComponentUtils { + + private static final String ADAPTER_TAG_NAME = "adapter"; + private static final String NAMESPACE_SEPARATOR = ":"; + private static final String NAMESPACE_DECLARATION_PREFIX = "xmlns"; + + public static List getNonCanvasComponents(Document configurationDocument) { + List nonCanvasComponents = new ArrayList<>(); + Element rootElement = configurationDocument.getDocumentElement(); + + if (rootElement == null) { + return nonCanvasComponents; + } + + NodeList childNodes = rootElement.getChildNodes(); + for (int childPosition = 0; childPosition < childNodes.getLength(); childPosition++) { + Node childNode = childNodes.item(childPosition); + + if (childNode.getNodeType() != Node.ELEMENT_NODE) { + continue; + } + + Element childElement = (Element) childNode; + if (isExcludedElement(childElement)) { + continue; + } + + nonCanvasComponents.add(childElement); + } + + return nonCanvasComponents; + } + + public static Element findNonCanvasComponent(Document configurationDocument, String tagName, int occurrenceIndex) { + int matchedOccurrences = 0; + + for (Element element : getNonCanvasComponents(configurationDocument)) { + if (!element.getTagName().equals(tagName)) { + continue; + } + + if (matchedOccurrences == occurrenceIndex) { + return element; + } + + matchedOccurrences++; + } + + return null; + } + + public static void addNonCanvasComponent(Document configurationDocument, String tagName, Map attributes) { + Element element = configurationDocument.createElement(tagName); + applyAttributes(element, attributes); + configurationDocument.getDocumentElement().appendChild(element); + } + + public static boolean updateNonCanvasComponent(Document configurationDocument, String tagName, int occurrenceIndex, Map attributes) { + Element element = findNonCanvasComponent(configurationDocument, tagName, occurrenceIndex); + + if (element == null) { + return false; + } + + removeRegularAttributes(element); + applyAttributes(element, attributes); + return true; + } + + public static boolean removeNonCanvasComponent(Document configurationDocument, String tagName, int occurrenceIndex) { + Element element = findNonCanvasComponent(configurationDocument, tagName, occurrenceIndex); + + if (element == null) { + return false; + } + + element.getParentNode().removeChild(element); + return true; + } + + public static Map getAttributes(Element element) { + Map attributes = new LinkedHashMap<>(); + NamedNodeMap attributeNodes = element.getAttributes(); + + for (int attributePosition = 0; attributePosition < attributeNodes.getLength(); attributePosition++) { + Node attributeNode = attributeNodes.item(attributePosition); + String attributeName = attributeNode.getNodeName(); + + if (isNamespaceDeclaration(attributeName)) { + continue; + } + + attributes.put(attributeName, attributeNode.getNodeValue()); + } + + return attributes; + } + + private static boolean isExcludedElement(Element element) { + String tagName = element.getTagName(); + + if (tagName.contains(NAMESPACE_SEPARATOR)) { + return true; + } + + return tagName.equalsIgnoreCase(ADAPTER_TAG_NAME); + } + + private static boolean isNamespaceDeclaration(String attributeName) { + return attributeName.equals(NAMESPACE_DECLARATION_PREFIX) || attributeName.startsWith(NAMESPACE_DECLARATION_PREFIX + NAMESPACE_SEPARATOR); + } + + private static void applyAttributes(Element element, Map attributes) { + if (attributes == null) { + return; + } + + for (Map.Entry attribute : attributes.entrySet()) { + String value = attribute.getValue(); + + if (value == null || value.isBlank()) { + continue; + } + + element.setAttribute(attribute.getKey(), value); + } + } + + private static void removeRegularAttributes(Element element) { + NamedNodeMap attributeNodes = element.getAttributes(); + List removableAttributeNames = new ArrayList<>(); + + for (int attributePosition = 0; attributePosition < attributeNodes.getLength(); attributePosition++) { + String attributeName = attributeNodes.item(attributePosition).getNodeName(); + + if (isNamespaceDeclaration(attributeName)) { + continue; + } + + removableAttributeNames.add(attributeName); + } + + for (String attributeName : removableAttributeNames) { + element.removeAttribute(attributeName); + } + } +} diff --git a/src/main/java/org/frankframework/flow/utility/XmlSecurityUtils.java b/src/main/java/org/frankframework/flow/utility/XmlSecurityUtils.java index e9af125a..4bb470b3 100644 --- a/src/main/java/org/frankframework/flow/utility/XmlSecurityUtils.java +++ b/src/main/java/org/frankframework/flow/utility/XmlSecurityUtils.java @@ -6,13 +6,35 @@ import javax.xml.parsers.ParserConfigurationException; import javax.xml.transform.TransformerFactory; import lombok.experimental.UtilityClass; +import lombok.extern.log4j.Log4j2; +import org.xml.sax.ErrorHandler; +import org.xml.sax.SAXException; +import org.xml.sax.SAXParseException; /** * Utility class for creating secure XML parsers and transformers that prevent XXE vulnerabilities. */ +@Log4j2 @UtilityClass public class XmlSecurityUtils { + private static final ErrorHandler QUIET_ERROR_HANDLER = new ErrorHandler() { + @Override + public void warning(SAXParseException exception) { + log.debug("XML parse warning: {}", exception.getMessage()); + } + + @Override + public void error(SAXParseException exception) throws SAXException { + throw exception; + } + + @Override + public void fatalError(SAXParseException exception) throws SAXException { + throw exception; + } + }; + /** * Creates a secure DocumentBuilderFactory configured to prevent XXE attacks. * @@ -23,10 +45,8 @@ public static DocumentBuilderFactory createSecureDocumentBuilderFactory() throws DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance(); factory.setNamespaceAware(true); factory.setIgnoringComments(true); - - // Prevent XXE vulnerabilities factory.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true); - factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true); + factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", false); factory.setFeature("http://xml.org/sax/features/external-general-entities", false); factory.setFeature("http://xml.org/sax/features/external-parameter-entities", false); factory.setFeature("http://apache.org/xml/features/nonvalidating/load-external-dtd", false); @@ -43,7 +63,9 @@ public static DocumentBuilderFactory createSecureDocumentBuilderFactory() throws * @throws ParserConfigurationException if the parser cannot be created */ public static DocumentBuilder createSecureDocumentBuilder() throws ParserConfigurationException { - return createSecureDocumentBuilderFactory().newDocumentBuilder(); + DocumentBuilder builder = createSecureDocumentBuilderFactory().newDocumentBuilder(); + builder.setErrorHandler(QUIET_ERROR_HANDLER); + return builder; } /** diff --git a/src/test/java/org/frankframework/flow/noncanvascomponent/NonCanvasComponentControllerTest.java b/src/test/java/org/frankframework/flow/noncanvascomponent/NonCanvasComponentControllerTest.java new file mode 100644 index 00000000..6dd89af1 --- /dev/null +++ b/src/test/java/org/frankframework/flow/noncanvascomponent/NonCanvasComponentControllerTest.java @@ -0,0 +1,144 @@ +package org.frankframework.flow.noncanvascomponent; + +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyMap; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import java.util.List; +import java.util.Map; +import org.frankframework.flow.exception.ApiException; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; +import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; + +@WebMvcTest(NonCanvasComponentController.class) +@AutoConfigureMockMvc(addFilters = false) +class NonCanvasComponentControllerTest { + + @Autowired + private MockMvc mockMvc; + + @MockitoBean + private NonCanvasComponentService nonCanvasComponentService; + + private static final String PROJECT = "FrankFlowTestProject"; + private static final String BASE_URL = "/api/projects/" + PROJECT + "/non-canvas-components"; + + @Test + void getNonCanvasComponents_returnsList() throws Exception { + NonCanvasComponentDTO component = new NonCanvasComponentDTO("Monitoring", "monitor", 0, Map.of("enabled", "true")); + when(nonCanvasComponentService.getNonCanvasComponents("config.xml")).thenReturn(List.of(component)); + + mockMvc.perform(get(BASE_URL).param("configurationPath", "config.xml").accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[0].tagName").value("Monitoring")) + .andExpect(jsonPath("$[0].name").value("monitor")) + .andExpect(jsonPath("$[0].index").value(0)) + .andExpect(jsonPath("$[0].attributes.enabled").value("true")); + + verify(nonCanvasComponentService).getNonCanvasComponents("config.xml"); + } + + @Test + void getNonCanvasComponents_missingConfigurationPath_returns400() throws Exception { + mockMvc.perform(get(BASE_URL).accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isBadRequest()); + } + + @Test + void getNonCanvasComponents_serviceThrowsNotFound_returns404() throws Exception { + when(nonCanvasComponentService.getNonCanvasComponents("missing.xml")) + .thenThrow(new ApiException("Configuration file not found: missing.xml", HttpStatus.NOT_FOUND)); + + mockMvc.perform(get(BASE_URL).param("configurationPath", "missing.xml").accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.status").value("Not Found")) + .andExpect(jsonPath("$.error").value("Configuration file not found: missing.xml")); + } + + @Test + void addNonCanvasComponent_deserializesBodyAndReturnsUpdatedList() throws Exception { + NonCanvasComponentDTO component = new NonCanvasComponentDTO("Scheduler", "daily", 0, Map.of("name", "daily")); + when(nonCanvasComponentService.addNonCanvasComponent("config.xml", "Scheduler", Map.of("name", "daily"))) + .thenReturn(List.of(component)); + + String body = "{\"configurationPath\":\"config.xml\",\"tagName\":\"Scheduler\",\"attributes\":{\"name\":\"daily\"}}"; + + mockMvc.perform(post(BASE_URL).contentType(MediaType.APPLICATION_JSON).content(body)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[0].tagName").value("Scheduler")) + .andExpect(jsonPath("$[0].name").value("daily")); + + verify(nonCanvasComponentService).addNonCanvasComponent("config.xml", "Scheduler", Map.of("name", "daily")); + } + + @Test + void updateNonCanvasComponent_deserializesBodyAndReturnsUpdatedList() throws Exception { + NonCanvasComponentDTO component = new NonCanvasComponentDTO("Monitoring", "monitor", 0, Map.of("enabled", "false")); + when(nonCanvasComponentService.updateNonCanvasComponent("config.xml", "Monitoring", 0, Map.of("enabled", "false"))) + .thenReturn(List.of(component)); + + String body = + "{\"configurationPath\":\"config.xml\",\"tagName\":\"Monitoring\",\"index\":0,\"attributes\":{\"enabled\":\"false\"}}"; + + mockMvc.perform(put(BASE_URL).contentType(MediaType.APPLICATION_JSON).content(body)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[0].attributes.enabled").value("false")); + + verify(nonCanvasComponentService).updateNonCanvasComponent("config.xml", "Monitoring", 0, Map.of("enabled", "false")); + } + + @Test + void updateNonCanvasComponent_serviceThrowsNotFound_returns404() throws Exception { + when(nonCanvasComponentService.updateNonCanvasComponent(anyString(), anyString(), anyInt(), anyMap())) + .thenThrow(new ApiException("Non-canvas component not found: Monitoring", HttpStatus.NOT_FOUND)); + + String body = + "{\"configurationPath\":\"config.xml\",\"tagName\":\"Monitoring\",\"index\":3,\"attributes\":{}}"; + + mockMvc.perform(put(BASE_URL).contentType(MediaType.APPLICATION_JSON).content(body)) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.error").value("Non-canvas component not found: Monitoring")); + } + + @Test + void deleteNonCanvasComponent_returnsRemainingList() throws Exception { + when(nonCanvasComponentService.deleteNonCanvasComponent("config.xml", "Scheduler", 0)).thenReturn(List.of()); + + mockMvc.perform(delete(BASE_URL) + .param("configurationPath", "config.xml") + .param("tagName", "Scheduler") + .param("index", "0")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$").isArray()) + .andExpect(jsonPath("$.length()").value(0)); + + verify(nonCanvasComponentService).deleteNonCanvasComponent("config.xml", "Scheduler", 0); + } + + @Test + void deleteNonCanvasComponent_serviceThrowsNotFound_returns404() throws Exception { + when(nonCanvasComponentService.deleteNonCanvasComponent("config.xml", "Scheduler", 9)) + .thenThrow(new ApiException("Non-canvas component not found: Scheduler", HttpStatus.NOT_FOUND)); + + mockMvc.perform(delete(BASE_URL) + .param("configurationPath", "config.xml") + .param("tagName", "Scheduler") + .param("index", "9")) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.error").value("Non-canvas component not found: Scheduler")); + } +} diff --git a/src/test/java/org/frankframework/flow/noncanvascomponent/NonCanvasComponentServiceTest.java b/src/test/java/org/frankframework/flow/noncanvascomponent/NonCanvasComponentServiceTest.java new file mode 100644 index 00000000..88ea5746 --- /dev/null +++ b/src/test/java/org/frankframework/flow/noncanvascomponent/NonCanvasComponentServiceTest.java @@ -0,0 +1,264 @@ +package org.frankframework.flow.noncanvascomponent; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; +import org.frankframework.flow.exception.ApiException; +import org.frankframework.flow.filesystem.FileSystemStorage; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.api.io.TempDir; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.HttpStatus; + +@ExtendWith(MockitoExtension.class) +class NonCanvasComponentServiceTest { + + @Mock + private FileSystemStorage fileSystemStorage; + + private NonCanvasComponentService nonCanvasComponentService; + + @TempDir + Path tempDir; + + private static final String CONFIGURATION = """ + + + + + + + + + + + + """; + + @BeforeEach + void setUp() { + nonCanvasComponentService = new NonCanvasComponentService(fileSystemStorage); + } + + private void stubToAbsolutePath() { + when(fileSystemStorage.toAbsolutePath(anyString())).thenAnswer(invocation -> { + String path = invocation.getArgument(0); + Path candidate = Path.of(path); + return candidate.isAbsolute() ? candidate : tempDir.resolve(candidate); + }); + } + + private void stubReadFile() throws IOException { + when(fileSystemStorage.readFile(anyString())).thenAnswer(invocation -> { + String path = invocation.getArgument(0); + return Files.readString(Path.of(path), StandardCharsets.UTF_8); + }); + } + + private void stubWriteFile() throws IOException { + doAnswer(invocation -> { + String path = invocation.getArgument(0); + String content = invocation.getArgument(1); + Files.writeString(Path.of(path), content, StandardCharsets.UTF_8); + return null; + }) + .when(fileSystemStorage) + .writeFile(anyString(), anyString()); + } + + private Path writeConfiguration(String content) throws IOException { + Path file = tempDir.resolve("configuration.xml"); + Files.writeString(file, content, StandardCharsets.UTF_8); + return file; + } + + @Test + void getNonCanvasComponents_returnsDirectChildrenExcludingAdapters() throws Exception { + stubToAbsolutePath(); + stubReadFile(); + Path file = writeConfiguration(CONFIGURATION); + + List components = nonCanvasComponentService.getNonCanvasComponents(file.toString()); + + assertEquals(2, components.size()); + assertEquals("Scheduler", components.get(0).tagName()); + assertNull(components.get(0).name()); + assertEquals("Monitoring", components.get(1).tagName()); + assertEquals("monitor", components.get(1).name()); + assertEquals("true", components.get(1).attributes().get("enabled")); + } + + @Test + void getNonCanvasComponents_assignsOccurrenceIndexPerTagName() throws Exception { + stubToAbsolutePath(); + stubReadFile(); + Path file = writeConfiguration(""" + + + + + + """); + + List components = nonCanvasComponentService.getNonCanvasComponents(file.toString()); + + assertEquals(3, components.size()); + assertEquals(0, components.get(0).index()); + assertEquals("first", components.get(0).name()); + assertEquals(0, components.get(1).index()); + assertEquals(1, components.get(2).index()); + assertEquals("second", components.get(2).name()); + } + + @Test + void getNonCanvasComponents_blankPath_throwsBadRequest() { + ApiException exception = + assertThrows(ApiException.class, () -> nonCanvasComponentService.getNonCanvasComponents(" ")); + assertEquals(HttpStatus.BAD_REQUEST, exception.getStatus()); + } + + @Test + void getNonCanvasComponents_fileNotFound_throwsNotFound() { + stubToAbsolutePath(); + String path = tempDir.resolve("missing.xml").toString(); + + ApiException exception = + assertThrows(ApiException.class, () -> nonCanvasComponentService.getNonCanvasComponents(path)); + assertEquals(HttpStatus.NOT_FOUND, exception.getStatus()); + } + + @Test + void getNonCanvasComponents_pathIsDirectory_throwsNotFound() throws IOException { + stubToAbsolutePath(); + Path directory = Files.createDirectory(tempDir.resolve("subdir")); + + ApiException exception = + assertThrows(ApiException.class, () -> nonCanvasComponentService.getNonCanvasComponents(directory.toString())); + assertEquals(HttpStatus.NOT_FOUND, exception.getStatus()); + } + + @Test + void getNonCanvasComponents_malformedXml_throwsInternalServerError() throws Exception { + stubToAbsolutePath(); + stubReadFile(); + Path file = writeConfiguration(""); + + ApiException exception = + assertThrows(ApiException.class, () -> nonCanvasComponentService.getNonCanvasComponents(file.toString())); + assertEquals(HttpStatus.INTERNAL_SERVER_ERROR, exception.getStatus()); + } + + @Test + void addNonCanvasComponent_appendsComponentAndPersists() throws Exception { + stubToAbsolutePath(); + stubReadFile(); + stubWriteFile(); + Path file = writeConfiguration(""); + + List components = nonCanvasComponentService.addNonCanvasComponent( + file.toString(), "Monitoring", Map.of("name", "monitor", "enabled", "true")); + + assertEquals(1, components.size()); + assertEquals("Monitoring", components.getFirst().tagName()); + assertEquals("monitor", components.getFirst().name()); + + verify(fileSystemStorage).writeFile(eq(file.toString()), anyString()); + String persisted = Files.readString(file, StandardCharsets.UTF_8); + assertTrue(persisted.contains(" nonCanvasComponentService.addNonCanvasComponent("configuration.xml", " ", Map.of())); + + assertEquals(HttpStatus.BAD_REQUEST, exception.getStatus()); + verify(fileSystemStorage, never()).writeFile(anyString(), anyString()); + } + + @Test + void updateNonCanvasComponent_replacesAttributesAndPersists() throws Exception { + stubToAbsolutePath(); + stubReadFile(); + stubWriteFile(); + Path file = writeConfiguration(""" + + + + """); + + List components = nonCanvasComponentService.updateNonCanvasComponent( + file.toString(), "Monitoring", 0, Map.of("name", "monitor", "enabled", "false")); + + assertEquals(1, components.size()); + assertEquals("false", components.getFirst().attributes().get("enabled")); + assertTrue(Files.readString(file, StandardCharsets.UTF_8).contains("enabled=\"false\"")); + } + + @Test + void updateNonCanvasComponent_notFound_throwsNotFoundAndDoesNotWrite() throws Exception { + stubToAbsolutePath(); + stubReadFile(); + Path file = writeConfiguration(""); + + ApiException exception = assertThrows( + ApiException.class, + () -> nonCanvasComponentService.updateNonCanvasComponent(file.toString(), "Scheduler", 0, Map.of("name", "x"))); + + assertEquals(HttpStatus.NOT_FOUND, exception.getStatus()); + verify(fileSystemStorage, never()).writeFile(anyString(), anyString()); + } + + @Test + void deleteNonCanvasComponent_removesComponentAndPersists() throws Exception { + stubToAbsolutePath(); + stubReadFile(); + stubWriteFile(); + Path file = writeConfiguration(""" + + + + + """); + + List components = nonCanvasComponentService.deleteNonCanvasComponent(file.toString(), "Scheduler", 0); + + assertEquals(1, components.size()); + assertEquals("Monitoring", components.getFirst().tagName()); + assertFalse(Files.readString(file, StandardCharsets.UTF_8).contains(""); + + ApiException exception = assertThrows( + ApiException.class, + () -> nonCanvasComponentService.deleteNonCanvasComponent(file.toString(), "Scheduler", 0)); + + assertEquals(HttpStatus.NOT_FOUND, exception.getStatus()); + verify(fileSystemStorage, never()).writeFile(anyString(), anyString()); + } +} diff --git a/src/test/java/org/frankframework/flow/utility/XmlNonCanvasComponentUtilsTest.java b/src/test/java/org/frankframework/flow/utility/XmlNonCanvasComponentUtilsTest.java new file mode 100644 index 00000000..3cc9b16a --- /dev/null +++ b/src/test/java/org/frankframework/flow/utility/XmlNonCanvasComponentUtilsTest.java @@ -0,0 +1,169 @@ +package org.frankframework.flow.utility; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.ByteArrayInputStream; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; +import org.w3c.dom.Document; +import org.w3c.dom.Element; + +class XmlNonCanvasComponentUtilsTest { + + private static final String CONFIGURATION = """ + + + + + + + + + + + + + """; + + private Document parseXml(String xml) throws Exception { + return XmlSecurityUtils.createSecureDocumentBuilder() + .parse(new ByteArrayInputStream(xml.getBytes(StandardCharsets.UTF_8))); + } + + @Test + void getNonCanvasComponents_excludesAdapters() throws Exception { + Document document = parseXml(CONFIGURATION); + + List components = XmlNonCanvasComponentUtils.getNonCanvasComponents(document); + + assertEquals(3, components.size()); + assertEquals("Scheduler", components.get(0).getTagName()); + assertEquals("Monitoring", components.get(1).getTagName()); + assertEquals("JmsRealms", components.get(2).getTagName()); + } + + @Test + void getNonCanvasComponents_excludesNamespacedFlowMetadata() throws Exception { + String xml = """ + + + + + """; + Document document = parseXml(xml); + + List components = XmlNonCanvasComponentUtils.getNonCanvasComponents(document); + + assertEquals(1, components.size()); + assertEquals("Scheduler", components.getFirst().getTagName()); + } + + @Test + void getAttributes_returnsRegularAttributesWithoutNamespaceDeclarations() throws Exception { + Document document = parseXml(CONFIGURATION); + Element monitoring = XmlNonCanvasComponentUtils.findNonCanvasComponent(document, "Monitoring", 0); + + Map attributes = XmlNonCanvasComponentUtils.getAttributes(monitoring); + + assertEquals(Map.of("name", "monitor", "enabled", "true"), attributes); + } + + @Test + void findNonCanvasComponent_returnsCorrectOccurrence() throws Exception { + String xml = """ + + + + + """; + Document document = parseXml(xml); + + Element second = XmlNonCanvasComponentUtils.findNonCanvasComponent(document, "Monitoring", 1); + + assertNotNull(second); + assertEquals("second", second.getAttribute("name")); + } + + @Test + void findNonCanvasComponent_returnsNullWhenOccurrenceMissing() throws Exception { + Document document = parseXml(CONFIGURATION); + + assertNull(XmlNonCanvasComponentUtils.findNonCanvasComponent(document, "Monitoring", 5)); + } + + @Test + void addNonCanvasComponent_appendsComponentWithAttributes() throws Exception { + Document document = parseXml(CONFIGURATION); + + XmlNonCanvasComponentUtils.addNonCanvasComponent(document, "SapSystems", Map.of("name", "sap")); + + Element added = XmlNonCanvasComponentUtils.findNonCanvasComponent(document, "SapSystems", 0); + assertNotNull(added); + assertEquals("sap", added.getAttribute("name")); + } + + @Test + void addNonCanvasComponent_skipsBlankAttributeValues() throws Exception { + Document document = parseXml(""); + + XmlNonCanvasComponentUtils.addNonCanvasComponent(document, "Job", Map.of("name", "nightly", "description", " ")); + + Element added = XmlNonCanvasComponentUtils.findNonCanvasComponent(document, "Job", 0); + assertEquals("nightly", added.getAttribute("name")); + assertFalse(added.hasAttribute("description")); + } + + @Test + void updateNonCanvasComponent_replacesAttributesAndKeepsChildren() throws Exception { + Document document = parseXml(CONFIGURATION); + + boolean updated = XmlNonCanvasComponentUtils.updateNonCanvasComponent(document, "Scheduler", 0, Map.of("name", "renamed")); + + assertTrue(updated); + Element scheduler = XmlNonCanvasComponentUtils.findNonCanvasComponent(document, "Scheduler", 0); + assertEquals("renamed", scheduler.getAttribute("name")); + assertEquals(1, scheduler.getElementsByTagName("Job").getLength()); + } + + @Test + void updateNonCanvasComponent_removesClearedAttributes() throws Exception { + Document document = parseXml(CONFIGURATION); + + XmlNonCanvasComponentUtils.updateNonCanvasComponent(document, "Monitoring", 0, Map.of("name", "monitor")); + + Element monitoring = XmlNonCanvasComponentUtils.findNonCanvasComponent(document, "Monitoring", 0); + assertEquals("monitor", monitoring.getAttribute("name")); + assertFalse(monitoring.hasAttribute("enabled")); + } + + @Test + void updateNonCanvasComponent_returnsFalseWhenMissing() throws Exception { + Document document = parseXml(CONFIGURATION); + + assertFalse(XmlNonCanvasComponentUtils.updateNonCanvasComponent(document, "Monitoring", 4, Map.of("name", "x"))); + } + + @Test + void removeNonCanvasComponent_removesComponent() throws Exception { + Document document = parseXml(CONFIGURATION); + + boolean removed = XmlNonCanvasComponentUtils.removeNonCanvasComponent(document, "Scheduler", 0); + + assertTrue(removed); + assertNull(XmlNonCanvasComponentUtils.findNonCanvasComponent(document, "Scheduler", 0)); + assertEquals(1, document.getElementsByTagName("Adapter").getLength()); + } + + @Test + void removeNonCanvasComponent_returnsFalseWhenMissing() throws Exception { + Document document = parseXml(CONFIGURATION); + + assertFalse(XmlNonCanvasComponentUtils.removeNonCanvasComponent(document, "Monitoring", 9)); + } +} diff --git a/src/test/java/org/frankframework/flow/utility/XmlSecurityUtilsTest.java b/src/test/java/org/frankframework/flow/utility/XmlSecurityUtilsTest.java new file mode 100644 index 00000000..e3bcf582 --- /dev/null +++ b/src/test/java/org/frankframework/flow/utility/XmlSecurityUtilsTest.java @@ -0,0 +1,66 @@ +package org.frankframework.flow.utility; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; + +import java.io.ByteArrayInputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import org.junit.jupiter.api.Test; +import org.w3c.dom.Document; + +class XmlSecurityUtilsTest { + + private Document parse(String xml) throws Exception { + return XmlSecurityUtils.createSecureDocumentBuilder() + .parse(new ByteArrayInputStream(xml.getBytes(StandardCharsets.UTF_8))); + } + + @Test + void createSecureDocumentBuilder_acceptsConfigurationWithDoctype() { + String xml = """ + + + + """; + + Document document = assertDoesNotThrow(() -> parse(xml)); + + assertEquals("Configuration", document.getDocumentElement().getTagName()); + assertEquals("Main", document.getDocumentElement().getAttribute("name")); + } + + @Test + void createSecureDocumentBuilder_acceptsInternalDtdSubsetWithEntities() { + String xml = """ + + ]> + + """; + + Document document = assertDoesNotThrow(() -> parse(xml)); + + assertEquals("Configuration", document.getDocumentElement().getTagName()); + } + + @Test + void createSecureDocumentBuilder_doesNotResolveExternalEntities() throws Exception { + Path secretFile = Files.createTempFile("xxe-secret", ".txt"); + try { + Files.writeString(secretFile, "TOP_SECRET_CONTENT"); + String systemId = secretFile.toUri().toString(); + + String xml = "\n" + + " ]>\n" + + "&xxe;"; + + Document document = assertDoesNotThrow(() -> parse(xml)); + + assertFalse(document.getDocumentElement().getTextContent().contains("TOP_SECRET_CONTENT")); + } finally { + Files.deleteIfExists(secretFile); + } + } +}