From 0fd3df2a3899aaad77c495cbfc8d9f8a8e0f5c65 Mon Sep 17 00:00:00 2001 From: stijnpotters Date: Wed, 24 Jun 2026 15:28:54 +0200 Subject: [PATCH 1/6] Add non-canvas element management with DTOs and service integration --- .../sidebars-layout/sidebar-layout.tsx | 34 +-- .../add-non-canvas-element-menu.tsx | 104 ++++++++ .../configuration-file-tile.tsx | 192 +++++++++++--- .../configurations/configuration-overview.tsx | 226 +++++++++++++---- .../non-canvas-element-context.tsx | 236 ++++++++++++++++++ .../non-canvas-element-palette.tsx | 73 ++++++ .../configurations/use-non-canvas-elements.ts | 47 ++++ .../services/non-canvas-element-service.ts | 78 ++++++ .../NonCanvasElementController.java | 51 ++++ .../NonCanvasElementCreateDTO.java | 5 + .../noncanvaselement/NonCanvasElementDTO.java | 5 + .../NonCanvasElementService.java | 135 ++++++++++ .../NonCanvasElementUpdateDTO.java | 5 + .../utility/XmlNonCanvasElementUtils.java | 161 ++++++++++++ .../flow/utility/XmlSecurityUtils.java | 30 ++- .../utility/XmlNonCanvasElementUtilsTest.java | 169 +++++++++++++ .../flow/utility/XmlSecurityUtilsTest.java | 66 +++++ 17 files changed, 1521 insertions(+), 96 deletions(-) create mode 100644 src/main/frontend/app/routes/configurations/add-non-canvas-element-menu.tsx create mode 100644 src/main/frontend/app/routes/configurations/non-canvas-element-context.tsx create mode 100644 src/main/frontend/app/routes/configurations/non-canvas-element-palette.tsx create mode 100644 src/main/frontend/app/routes/configurations/use-non-canvas-elements.ts create mode 100644 src/main/frontend/app/services/non-canvas-element-service.ts create mode 100644 src/main/java/org/frankframework/flow/noncanvaselement/NonCanvasElementController.java create mode 100644 src/main/java/org/frankframework/flow/noncanvaselement/NonCanvasElementCreateDTO.java create mode 100644 src/main/java/org/frankframework/flow/noncanvaselement/NonCanvasElementDTO.java create mode 100644 src/main/java/org/frankframework/flow/noncanvaselement/NonCanvasElementService.java create mode 100644 src/main/java/org/frankframework/flow/noncanvaselement/NonCanvasElementUpdateDTO.java create mode 100644 src/main/java/org/frankframework/flow/utility/XmlNonCanvasElementUtils.java create mode 100644 src/test/java/org/frankframework/flow/utility/XmlNonCanvasElementUtilsTest.java create mode 100644 src/test/java/org/frankframework/flow/utility/XmlSecurityUtilsTest.java 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..255510b2 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,18 +41,20 @@ export default function SidebarLayout({ if (!allotmentReady || !allotmentRef.current) return if (sizes.length === 0) return - const target = sizes.map((size, i) => (visible[i] ? size : 0)) + let target = sizes.map((size, i) => (visible[i] ? size : 0)) + if (hideLeft) target = target.slice(1) allotmentRef.current.resize(target) - }, [sizes, visible, allotmentReady]) + }, [sizes, visible, allotmentReady, hideLeft]) const handleVisibilityChange = (index: SidebarSide, value: boolean) => { - setVisible(name, index, value) + setVisible(name, hideLeft ? index + 1 : index, value) } const saveSizes = (newSizes: number[]) => { const previous = useSidebarStore.getState().getSizes(name) ?? [] - const merged = newSizes.map((size, i) => (size === 0 ? (previous[i] ?? 0) : size)) + const aligned = hideLeft ? [previous[0] ?? 0, ...newSizes] : newSizes + const merged = aligned.map((size, i) => (size === 0 ? (previous[i] ?? 0) : size)) setSizes(name, merged) if (windowResizeOnChange) { globalThis.dispatchEvent(new Event('resize')) @@ -71,16 +75,18 @@ export default function SidebarLayout({ onDragEnd={saveSizes} onVisibleChange={handleVisibilityChange} > - - {childrenArray[SidebarSide.LEFT]} - + {!hideLeft && ( + + {childrenArray[SidebarSide.LEFT]} + + )} {childrenArray[SidebarSide.MIDDLE]} diff --git a/src/main/frontend/app/routes/configurations/add-non-canvas-element-menu.tsx b/src/main/frontend/app/routes/configurations/add-non-canvas-element-menu.tsx new file mode 100644 index 00000000..c470157e --- /dev/null +++ b/src/main/frontend/app/routes/configurations/add-non-canvas-element-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 { getAddableNonCanvasElementNames } from '~/services/non-canvas-element-service' + +interface AddNonCanvasElementMenuProperties { + isOpen: boolean + onClose: () => void + onSelect: (tagName: string) => void +} + +export default function AddNonCanvasElementMenu({ + isOpen, + onClose, + onSelect, +}: Readonly) { + const { elements } = useFFDoc() + const { xsdContent } = useFrankConfigXsd() + const [search, setSearch] = useState('') + const [selected, setSelected] = useState(null) + + const addableNames = useMemo(() => getAddableNonCanvasElementNames(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 element

+ + +
+
    + {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 elements found.' : 'No results found.'} +
  • + )} +
+
+ + +
+
, + document.body, + ) +} 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..1a97031e 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,87 @@ 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 TuningIcon from '/icons/solar/Tuning.svg?react' import { useNavigate } from 'react-router' 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 { NON_CANVAS_DRAG_TYPE, type NonCanvasElement } from '~/services/non-canvas-element-service' +import {useRef, useState} from 'react' import { getBaseName } from '~/utils/path-utils' type ConfigurationFileTileProperties = { filepath: string relativePath: string adapterNames: string[] + nonCanvasElements: NonCanvasElement[] + loadingElements: boolean onDelete: () => Promise + onAddElement: (configurationPath: string) => void + onEditElement: (configurationPath: string, element: NonCanvasElement) => void + onDropElement: (configurationPath: string, tagName: string) => void + dragActive?: boolean +} + +function isRootConfiguration(relativePath: string): boolean { + return relativePath.split(/[/\\]/).pop()?.toLowerCase() === 'configuration.xml' +} + +function isElementDrag(event: DragEvent): boolean { + return event.dataTransfer.types.includes(NON_CANVAS_DRAG_TYPE) +} + +function handleDragOver(event: DragEvent) { + if (!isElementDrag(event)) return + event.preventDefault() + event.dataTransfer.dropEffect = 'copy' } export default function ConfigurationFileTile({ filepath, relativePath, adapterNames, + nonCanvasElements, + loadingElements, onDelete, + onAddElement, + onEditElement, + onDropElement, + dragActive = false, }: Readonly) { const [showDeleteDialog, setShowDeleteDialog] = useState(false) + const [isDropTarget, setIsDropTarget] = useState(false) + const dragDepth = useRef(0) const navigate = useNavigate() + const handleDragEnter = (event: DragEvent) => { + if (!isElementDrag(event)) return + event.preventDefault() + dragDepth.current += 1 + setIsDropTarget(true) + } + + const handleDragLeave = (event: DragEvent) => { + if (!isElementDrag(event)) return + dragDepth.current -= 1 + if (dragDepth.current <= 0) { + dragDepth.current = 0 + setIsDropTarget(false) + } + } + + const handleDrop = (event: DragEvent) => { + if (!isElementDrag(event)) return + event.preventDefault() + dragDepth.current = 0 + setIsDropTarget(false) + const tagName = event.dataTransfer.getData(NON_CANVAS_DRAG_TYPE) + if (tagName) onDropElement(filepath, tagName) + } + const handleOpenInStudio = (adapterName: string, adapterPosition: number) => { openInStudio(navigate, { adapterName, filepath, adapterPosition }) } @@ -40,45 +97,91 @@ export default function ConfigurationFileTile({ setShowDeleteDialog(false) } + const hasContent = adapterNames.length > 0 || nonCanvasElements.length > 0 + + let dropZoneClasses = 'border-border' + if (isDropTarget) { + dropZoneClasses = 'border-foreground-active ring-foreground-active border-dashed ring-2' + } else if (dragActive) { + dropZoneClasses = 'border-foreground-active/50 border-dashed' + } + + let elementList + if (loadingElements && adapterNames.length === 0) { + elementList = ( +
+ +
+ ) + } else if (hasContent) { + elementList = ( +
    + {adapterNames.map((adapterName, adapterPosition) => ( + + ))} + {nonCanvasElements.map((element) => ( + onEditElement(filepath, element)} + /> + ))} +
+ ) + } else { + elementList =
No adapters or elements found
+ } + return ( -
-
-

- {relativePath} -

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

+ {relativePath} +

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

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

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

Adapters & elements

+
{elementList}
+
-
+
} label="Open in Editor" onClick={handleOpenInEditor} /> + } + label="Add non-canvas element" + onClick={() => onAddElement(filepath)} + />
{showDeleteDialog && ( @@ -94,22 +197,39 @@ export default function ConfigurationFileTile({ } type AdapterListItemProperties = { - name: string + adapterName: string adapterPosition: number - onOpenInStudio: (name: string, adapterPosition: number) => void + onOpenInStudio: (adapterName: string, adapterPosition: number) => void } -function AdapterListItem({ name, adapterPosition, onOpenInStudio }: Readonly) { +function AdapterListItem({ adapterName, adapterPosition, onOpenInStudio }: Readonly) { return ( -
  • - - {name} +
  • + + {adapterName} } label="Open in Studio" - onClick={() => onOpenInStudio(name, adapterPosition)} + onClick={() => onOpenInStudio(adapterName, adapterPosition)} />
  • ) } + +type NonCanvasElementListItemProperties = { + element: NonCanvasElement + onConfigure: () => void +} + +function NonCanvasElementListItem({ element, onConfigure }: Readonly) { + const label = element.name ? `${element.tagName} · ${element.name}` : element.tagName + return ( +
  • + + {label} + + } label="Configure" onClick={onConfigure} /> +
  • + ) +} diff --git a/src/main/frontend/app/routes/configurations/configuration-overview.tsx b/src/main/frontend/app/routes/configurations/configuration-overview.tsx index 2caecbc1..8214f258 100644 --- a/src/main/frontend/app/routes/configurations/configuration-overview.tsx +++ b/src/main/frontend/app/routes/configurations/configuration-overview.tsx @@ -11,6 +11,17 @@ 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 NonCanvasElementContext, { type NonCanvasEditorState } from './non-canvas-element-context' +import NonCanvasElementPalette from './non-canvas-element-palette' +import AddNonCanvasElementMenu from './add-non-canvas-element-menu' +import type { NonCanvasElement } from '~/services/non-canvas-element-service' +import { useNonCanvasElements } from './use-non-canvas-elements' + +const SIDEBAR_NAME = 'configuration-overview-v2' import { relativeTo } from '~/utils/path-utils' type ConfigurationFile = { @@ -44,6 +55,59 @@ export default function ConfigurationOverview() { const [isLoading, setIsLoading] = useState(false) const [searchQuery, setSearchQuery] = useState('') const [debouncedQuery, setDebouncedQuery] = useState(searchQuery) + const [editor, setEditor] = useState(null) + const [editorName, setEditorName] = useState('') + const [addMenuConfigPath, setAddMenuConfigPath] = useState(null) + const [isDraggingFromPalette, setIsDraggingFromPalette] = useState(false) + + const setSidebarVisible = useSidebarStore((state) => state.setVisible) + + const openEditor = useCallback( + (state: NonCanvasEditorState) => { + setEditor(state) + setEditorName(state.initialAttributes?.name ?? '') + setSidebarVisible(SIDEBAR_NAME, SidebarSide.RIGHT, true) + }, + [setSidebarVisible], + ) + + const closeEditor = useCallback(() => { + setEditor(null) + setEditorName('') + }, []) + + const handleAddElement = useCallback((configPath: string) => { + setAddMenuConfigPath(configPath) + }, []) + + const handleSelectElementType = useCallback( + (tagName: string) => { + if (!addMenuConfigPath) return + openEditor({ mode: 'add', configPath: addMenuConfigPath, tagName }) + setAddMenuConfigPath(null) + }, + [addMenuConfigPath, openEditor], + ) + + const handleEditElement = useCallback( + (configurationPath: string, element: NonCanvasElement) => { + openEditor({ + mode: 'edit', + configPath: configurationPath, + tagName: element.tagName, + index: element.index, + initialAttributes: element.attributes, + }) + }, + [openEditor], + ) + + const handleDropElement = useCallback( + (configurationPath: string, tagName: string) => { + openEditor({ mode: 'add', configPath: configurationPath, tagName }) + }, + [openEditor], + ) const loadTree = useCallback( (signal?: AbortSignal) => { @@ -104,27 +168,46 @@ 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 = a.relativePath.split(/[/\\]/).pop()?.toLowerCase() === 'configuration.xml' + const bIsRoot = b.relativePath.split(/[/\\]/).pop()?.toLowerCase() === 'configuration.xml' + 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 { elementsByPath, loadingByPath, replaceElements } = useNonCanvasElements( + currentConfigurationProject?.name ?? '', + configurationPaths, + ) + + const handleElementSaved = useCallback( + (configurationPath: string, elements: NonCanvasElement[]) => { + replaceElements(configurationPath, elements) + closeEditor() + }, + [replaceElements, closeEditor], + ) if (!currentConfigurationProject) { return ( @@ -145,40 +228,99 @@ export default function ConfigurationOverview() { ) } + let sidebarTitle = 'Elements' + if (editor) { + if (editorName) { + sidebarTitle = `${editor.tagName} · ${editorName}` + } else { + sidebarTitle = editor.mode === 'add' ? `Add ${editor.tagName}` : editor.tagName + } + } + return ( -
    -
    navigate('/')}> - -

    Switch configuration

    -
    +
    + + {/* Left slot is intentionally unused; hideLeft keeps it from rendering. */} + <> -

    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)} + onAddElement={handleAddElement} + onEditElement={handleEditElement} + onDropElement={handleDropElement} + /> + ))} + + setShowModal(true)} /> +
    + + setShowModal(false)} + onSuccess={handleConfigAdded} + currentConfiguration={currentConfigurationProject} + configurationsDirPath={tree?.path ?? ''} + /> +
    +
    + + <> + + {editor ? ( + handleElementSaved(editor.configPath, elements)} + onClose={closeEditor} + onNameChange={setEditorName} + /> + ) : ( + + )} + + + + setAddMenuConfigPath(null)} + onSelect={handleSelectElementType} />
    ) diff --git a/src/main/frontend/app/routes/configurations/non-canvas-element-context.tsx b/src/main/frontend/app/routes/configurations/non-canvas-element-context.tsx new file mode 100644 index 00000000..48d4b889 --- /dev/null +++ b/src/main/frontend/app/routes/configurations/non-canvas-element-context.tsx @@ -0,0 +1,236 @@ +import { useCallback, useEffect, useMemo, useState } from 'react' +import { useFFDoc } from '@frankframework/doc-library-react' +import type { Attribute } from '@frankframework/doc-library-core' +import Button from '~/components/inputs/button' +import ContextInput from '~/routes/studio/context/context-input' +import LoadingSpinner from '~/components/loading-spinner' +import { + addNonCanvasElement, + deleteNonCanvasElement, + updateNonCanvasElement, + type NonCanvasElement, +} from '~/services/non-canvas-element-service' + +export interface NonCanvasEditorState { + mode: 'add' | 'edit' + configPath: string + tagName: string + index?: number + initialAttributes?: Record +} + +interface NonCanvasElementContextProperties { + projectName: string + editor: NonCanvasEditorState + onSaved: (elements: NonCanvasElement[]) => void + onClose: () => void + onNameChange?: (name: string) => void +} + +const EMPTY_ATTRIBUTE: Attribute = {} + +export default function NonCanvasElementContext({ + 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>( + () => elements?.[tagName]?.attributes ?? {}, + [elements, 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 elementName = inputValues['name']?.trim() ?? '' + + useEffect(() => { + onNameChange?.(elementName) + }, [elementName, 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 (attributeDefinitions[key]?.mandatory) mandatory.push(key) + else if (initiallyFilledKeys.has(key)) filled.push(key) + else rest.push(key) + } + + return { baseKeys: [...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' + ? addNonCanvasElement(projectName, configPath, tagName, attributes) + : updateNonCanvasElement(projectName, configPath, tagName, index ?? 0, attributes) + const updatedElements = await pendingSave + onSaved(updatedElements) + } catch (error) { + setErrorMessage(error instanceof Error ? error.message : `Failed to save ${tagName}`) + } finally { + setIsSaving(false) + } + } + + const handleDelete = async () => { + setIsSaving(true) + setErrorMessage('') + + try { + const updatedElements = await deleteNonCanvasElement(projectName, configPath, tagName, index ?? 0) + onSaved(updatedElements) + } catch (error) { + setErrorMessage(error instanceof Error ? error.message : `Failed to delete ${tagName}`) + setIsSaving(false) + } + } + + if (isLoading || !elements) { + return ( +
    + +
    + ) + } + + return ( +
    +
    +
    {tagName}
    + {elementName &&

    {elementName}

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

    This element 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 && ( +
    + +
    + )} +
    +
    + +
    +
    + + +
    + + {mode === 'edit' && ( + + )} +
    +
    + + {errorMessage &&

    {errorMessage}

    } +
    +
    + ) +} diff --git a/src/main/frontend/app/routes/configurations/non-canvas-element-palette.tsx b/src/main/frontend/app/routes/configurations/non-canvas-element-palette.tsx new file mode 100644 index 00000000..53d35806 --- /dev/null +++ b/src/main/frontend/app/routes/configurations/non-canvas-element-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 { getAddableNonCanvasElementNames, NON_CANVAS_DRAG_TYPE } from '~/services/non-canvas-element-service' + +/** + * Palette of non-canvas elements that can be dragged onto a configuration tile to add them. + * Shares the look and drag behaviour of the studio element palette, + * but is scoped to the elements that are only allowed as direct children of a Configuration/Module. + */ +export default function NonCanvasElementPalette({ + onDragActiveChange, +}: { + onDragActiveChange?: (active: boolean) => void +}) { + const { elements, isLoading } = useFFDoc() + const { xsdContent } = useFrankConfigXsd() + const [search, setSearch] = useState('') + + const addableNames = useMemo(() => getAddableNonCanvasElementNames(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(NON_CANVAS_DRAG_TYPE, tagName) + event.dataTransfer.effectAllowed = 'copy' + onDragActiveChange?.(true) + } + + const handleDragEnd = () => onDragActiveChange?.(false) + + return ( +
    + + + {isLoading || !elements || !xsdContent ? ( +
    + +
    + ) : ( +
      + {filteredNames.length === 0 ? ( +
    • + {addableNames.length === 0 ? 'No addable elements found.' : 'No results found.'} +
    • + ) : ( + filteredNames.map((name) => ( +
    • + {name} +
    • + )) + )} +
    + )} +
    + ) +} diff --git a/src/main/frontend/app/routes/configurations/use-non-canvas-elements.ts b/src/main/frontend/app/routes/configurations/use-non-canvas-elements.ts new file mode 100644 index 00000000..79ee6a78 --- /dev/null +++ b/src/main/frontend/app/routes/configurations/use-non-canvas-elements.ts @@ -0,0 +1,47 @@ +import { useCallback, useEffect, useState } from 'react' +import { getNonCanvasElementsFromConfiguration, type NonCanvasElement } from '~/services/non-canvas-element-service' + +const PATH_SEPARATOR = '\n' + +interface NonCanvasElementsState { + elementsByPath: Record + loadingByPath: Record + replaceElements: (configurationPath: string, elements: NonCanvasElement[]) => void +} + +export function useNonCanvasElements(projectName: string, configurationPaths: string[]): NonCanvasElementsState { + const [elementsByPath, setElementsByPath] = useState>({}) + const [loadingByPath, setLoadingByPath] = useState>({}) + const pathsKey = configurationPaths.join(PATH_SEPARATOR) + + useEffect(() => { + const paths = pathsKey ? pathsKey.split(PATH_SEPARATOR) : [] + const controller = new AbortController() + + setLoadingByPath(Object.fromEntries(paths.map((path) => [path, true]))) + + for (const path of paths) { + getNonCanvasElementsFromConfiguration(projectName, path, controller.signal) + .then((elements) => { + if (controller.signal.aborted) return + setElementsByPath((previous) => ({ ...previous, [path]: elements })) + }) + .catch(() => { + if (controller.signal.aborted) return + setElementsByPath((previous) => ({ ...previous, [path]: [] })) + }) + .finally(() => { + if (controller.signal.aborted) return + setLoadingByPath((previous) => ({ ...previous, [path]: false })) + }) + } + + return () => controller.abort() + }, [projectName, pathsKey]) + + const replaceElements = useCallback((configurationPath: string, elements: NonCanvasElement[]) => { + setElementsByPath((previous) => ({ ...previous, [configurationPath]: elements })) + }, []) + + return { elementsByPath, loadingByPath, replaceElements } +} diff --git a/src/main/frontend/app/services/non-canvas-element-service.ts b/src/main/frontend/app/services/non-canvas-element-service.ts new file mode 100644 index 00000000..d47aca21 --- /dev/null +++ b/src/main/frontend/app/services/non-canvas-element-service.ts @@ -0,0 +1,78 @@ +import { apiFetch } from '~/utils/api' +import { parseXsd, getFirstLevelElementsForType } from '~/utils/xsd-utils' +import type { Elements } from '@frankframework/doc-library-core' + +export interface NonCanvasElement { + tagName: string + name: string | null + index: number + attributes: Record +} + +const ROOT_TYPE_CANDIDATES = ['ConfigurationType', 'ModuleType', 'Configuration', 'Module'] + +/** MIME-like key used to carry a non-canvas element tag name across a drag-and-drop onto a configuration tile. */ +export const NON_CANVAS_DRAG_TYPE = 'application/x-noncanvas-element' + +function getBaseUrl(projectName: string): string { + return `/projects/${encodeURIComponent(projectName)}/non-canvas-elements` +} + +export function getAddableNonCanvasElementNames(xsdContent: string | null, elements: Elements | null): string[] { + if (!xsdContent || !elements) return [] + + const xsdDocument = parseXsd(xsdContent) + const names = new Set() + for (const rootType of ROOT_TYPE_CANDIDATES) { + for (const name of getFirstLevelElementsForType(xsdDocument, rootType)) names.add(name) + } + names.delete('Adapter') + + return [...names].filter((name) => elements[name]).toSorted((first, second) => first.localeCompare(second)) +} + +export async function getNonCanvasElementsFromConfiguration( + projectName: string, + configurationPath: string, + signal?: AbortSignal, +): Promise { + return apiFetch( + `${getBaseUrl(projectName)}?configurationPath=${encodeURIComponent(configurationPath)}`, + { signal }, + ) +} + +export async function addNonCanvasElement( + projectName: string, + configurationPath: string, + tagName: string, + attributes: Record, +): Promise { + return apiFetch(getBaseUrl(projectName), { + method: 'POST', + body: JSON.stringify({ configurationPath, tagName, attributes }), + }) +} + +export async function updateNonCanvasElement( + 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 deleteNonCanvasElement( + 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/noncanvaselement/NonCanvasElementController.java b/src/main/java/org/frankframework/flow/noncanvaselement/NonCanvasElementController.java new file mode 100644 index 00000000..91d577b5 --- /dev/null +++ b/src/main/java/org/frankframework/flow/noncanvaselement/NonCanvasElementController.java @@ -0,0 +1,51 @@ +package org.frankframework.flow.noncanvaselement; + +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-elements") +public class NonCanvasElementController { + + private final NonCanvasElementService nonCanvasElementService; + + public NonCanvasElementController(NonCanvasElementService nonCanvasElementService) { + this.nonCanvasElementService = nonCanvasElementService; + } + + @GetMapping + public ResponseEntity> getNonCanvasElements(@RequestParam String configurationPath) { + return ResponseEntity.ok(nonCanvasElementService.getNonCanvasElements(configurationPath)); + } + + @PostMapping + public ResponseEntity> addNonCanvasElement(@RequestBody NonCanvasElementCreateDTO request) { + List elements = + nonCanvasElementService.addNonCanvasElement(request.configurationPath(), request.tagName(), request.attributes()); + return ResponseEntity.ok(elements); + } + + @PutMapping + public ResponseEntity> updateNonCanvasElement(@RequestBody NonCanvasElementUpdateDTO request) { + List elements = nonCanvasElementService.updateNonCanvasElement( + request.configurationPath(), request.tagName(), request.index(), request.attributes()); + return ResponseEntity.ok(elements); + } + + @DeleteMapping + public ResponseEntity> deleteNonCanvasElement( + @RequestParam String configurationPath, + @RequestParam String tagName, + @RequestParam int index) { + List elements = nonCanvasElementService.deleteNonCanvasElement(configurationPath, tagName, index); + return ResponseEntity.ok(elements); + } +} diff --git a/src/main/java/org/frankframework/flow/noncanvaselement/NonCanvasElementCreateDTO.java b/src/main/java/org/frankframework/flow/noncanvaselement/NonCanvasElementCreateDTO.java new file mode 100644 index 00000000..23af01df --- /dev/null +++ b/src/main/java/org/frankframework/flow/noncanvaselement/NonCanvasElementCreateDTO.java @@ -0,0 +1,5 @@ +package org.frankframework.flow.noncanvaselement; + +import java.util.Map; + +public record NonCanvasElementCreateDTO(String configurationPath, String tagName, Map attributes) {} diff --git a/src/main/java/org/frankframework/flow/noncanvaselement/NonCanvasElementDTO.java b/src/main/java/org/frankframework/flow/noncanvaselement/NonCanvasElementDTO.java new file mode 100644 index 00000000..3ea5d272 --- /dev/null +++ b/src/main/java/org/frankframework/flow/noncanvaselement/NonCanvasElementDTO.java @@ -0,0 +1,5 @@ +package org.frankframework.flow.noncanvaselement; + +import java.util.Map; + +public record NonCanvasElementDTO(String tagName, String name, int index, Map attributes) {} diff --git a/src/main/java/org/frankframework/flow/noncanvaselement/NonCanvasElementService.java b/src/main/java/org/frankframework/flow/noncanvaselement/NonCanvasElementService.java new file mode 100644 index 00000000..f9f46ccf --- /dev/null +++ b/src/main/java/org/frankframework/flow/noncanvaselement/NonCanvasElementService.java @@ -0,0 +1,135 @@ +package org.frankframework.flow.noncanvaselement; + +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.XmlNonCanvasElementUtils; +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 NonCanvasElementService { + + private static final String NAME_ATTRIBUTE = "name"; + + private final FileSystemStorage fileSystemStorage; + + public NonCanvasElementService(FileSystemStorage fileSystemStorage) { + this.fileSystemStorage = fileSystemStorage; + } + + public List getNonCanvasElements(String configurationPath) { + Document configurationDocument = readConfigurationDocument(configurationPath); + return toDataTransferObjects(configurationDocument); + } + + public List addNonCanvasElement(String configurationPath, String tagName, Map attributes) { + validateTagName(tagName); + Document configurationDocument = readConfigurationDocument(configurationPath); + XmlNonCanvasElementUtils.addNonCanvasElement(configurationDocument, tagName, attributes); + return writeAndList(configurationPath, configurationDocument); + } + + public List updateNonCanvasElement(String configurationPath, String tagName, int index, Map attributes) { + validateTagName(tagName); + Document configurationDocument = readConfigurationDocument(configurationPath); + boolean updated = XmlNonCanvasElementUtils.updateNonCanvasElement(configurationDocument, tagName, index, attributes); + + if (!updated) { + throw new ApiException("Non-canvas element not found: " + tagName, HttpStatus.NOT_FOUND); + } + + return writeAndList(configurationPath, configurationDocument); + } + + public List deleteNonCanvasElement(String configurationPath, String tagName, int index) { + validateTagName(tagName); + Document configurationDocument = readConfigurationDocument(configurationPath); + boolean removed = XmlNonCanvasElementUtils.removeNonCanvasElement(configurationDocument, tagName, index); + + if (!removed) { + throw new ApiException("Non-canvas element 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 elements = new ArrayList<>(); + Map occurrenceByTagName = new HashMap<>(); + + for (Element element : XmlNonCanvasElementUtils.getNonCanvasElements(configurationDocument)) { + String tagName = element.getTagName(); + int index = occurrenceByTagName.merge(tagName, 1, Integer::sum) - 1; + Map attributes = XmlNonCanvasElementUtils.getAttributes(element); + String name = attributes.get(NAME_ATTRIBUTE); + elements.add(new NonCanvasElementDTO(tagName, name, index, attributes)); + } + + return elements; + } + + 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.BAD_REQUEST); + } + } + + 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.BAD_REQUEST); + } + } + + 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("Element type must not be empty", HttpStatus.BAD_REQUEST); + } + } +} diff --git a/src/main/java/org/frankframework/flow/noncanvaselement/NonCanvasElementUpdateDTO.java b/src/main/java/org/frankframework/flow/noncanvaselement/NonCanvasElementUpdateDTO.java new file mode 100644 index 00000000..c06f3e33 --- /dev/null +++ b/src/main/java/org/frankframework/flow/noncanvaselement/NonCanvasElementUpdateDTO.java @@ -0,0 +1,5 @@ +package org.frankframework.flow.noncanvaselement; + +import java.util.Map; + +public record NonCanvasElementUpdateDTO(String configurationPath, String tagName, int index, Map attributes) {} diff --git a/src/main/java/org/frankframework/flow/utility/XmlNonCanvasElementUtils.java b/src/main/java/org/frankframework/flow/utility/XmlNonCanvasElementUtils.java new file mode 100644 index 00000000..561ff046 --- /dev/null +++ b/src/main/java/org/frankframework/flow/utility/XmlNonCanvasElementUtils.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 XmlNonCanvasElementUtils { + + 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 getNonCanvasElements(Document configurationDocument) { + List nonCanvasElements = new ArrayList<>(); + Element rootElement = configurationDocument.getDocumentElement(); + + if (rootElement == null) { + return nonCanvasElements; + } + + 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; + } + + nonCanvasElements.add(childElement); + } + + return nonCanvasElements; + } + + public static Element findNonCanvasElement(Document configurationDocument, String tagName, int occurrenceIndex) { + int matchedOccurrences = 0; + + for (Element element : getNonCanvasElements(configurationDocument)) { + if (!element.getTagName().equals(tagName)) { + continue; + } + + if (matchedOccurrences == occurrenceIndex) { + return element; + } + + matchedOccurrences++; + } + + return null; + } + + public static void addNonCanvasElement(Document configurationDocument, String tagName, Map attributes) { + Element element = configurationDocument.createElement(tagName); + applyAttributes(element, attributes); + configurationDocument.getDocumentElement().appendChild(element); + } + + public static boolean updateNonCanvasElement(Document configurationDocument, String tagName, int occurrenceIndex, Map attributes) { + Element element = findNonCanvasElement(configurationDocument, tagName, occurrenceIndex); + + if (element == null) { + return false; + } + + removeRegularAttributes(element); + applyAttributes(element, attributes); + return true; + } + + public static boolean removeNonCanvasElement(Document configurationDocument, String tagName, int occurrenceIndex) { + Element element = findNonCanvasElement(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/utility/XmlNonCanvasElementUtilsTest.java b/src/test/java/org/frankframework/flow/utility/XmlNonCanvasElementUtilsTest.java new file mode 100644 index 00000000..d09e6e56 --- /dev/null +++ b/src/test/java/org/frankframework/flow/utility/XmlNonCanvasElementUtilsTest.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 XmlNonCanvasElementUtilsTest { + + 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 getNonCanvasElements_excludesAdapters() throws Exception { + Document document = parseXml(CONFIGURATION); + + List elements = XmlNonCanvasElementUtils.getNonCanvasElements(document); + + assertEquals(3, elements.size()); + assertEquals("Scheduler", elements.get(0).getTagName()); + assertEquals("Monitoring", elements.get(1).getTagName()); + assertEquals("JmsRealms", elements.get(2).getTagName()); + } + + @Test + void getNonCanvasElements_excludesNamespacedFlowMetadata() throws Exception { + String xml = """ + + + + + """; + Document document = parseXml(xml); + + List elements = XmlNonCanvasElementUtils.getNonCanvasElements(document); + + assertEquals(1, elements.size()); + assertEquals("Scheduler", elements.getFirst().getTagName()); + } + + @Test + void getAttributes_returnsRegularAttributesWithoutNamespaceDeclarations() throws Exception { + Document document = parseXml(CONFIGURATION); + Element monitoring = XmlNonCanvasElementUtils.findNonCanvasElement(document, "Monitoring", 0); + + Map attributes = XmlNonCanvasElementUtils.getAttributes(monitoring); + + assertEquals(Map.of("name", "monitor", "enabled", "true"), attributes); + } + + @Test + void findNonCanvasElement_returnsCorrectOccurrence() throws Exception { + String xml = """ + + + + + """; + Document document = parseXml(xml); + + Element second = XmlNonCanvasElementUtils.findNonCanvasElement(document, "Monitoring", 1); + + assertNotNull(second); + assertEquals("second", second.getAttribute("name")); + } + + @Test + void findNonCanvasElement_returnsNullWhenOccurrenceMissing() throws Exception { + Document document = parseXml(CONFIGURATION); + + assertNull(XmlNonCanvasElementUtils.findNonCanvasElement(document, "Monitoring", 5)); + } + + @Test + void addNonCanvasElement_appendsElementWithAttributes() throws Exception { + Document document = parseXml(CONFIGURATION); + + XmlNonCanvasElementUtils.addNonCanvasElement(document, "SapSystems", Map.of("name", "sap")); + + Element added = XmlNonCanvasElementUtils.findNonCanvasElement(document, "SapSystems", 0); + assertNotNull(added); + assertEquals("sap", added.getAttribute("name")); + } + + @Test + void addNonCanvasElement_skipsBlankAttributeValues() throws Exception { + Document document = parseXml(""); + + XmlNonCanvasElementUtils.addNonCanvasElement(document, "Job", Map.of("name", "nightly", "description", " ")); + + Element added = XmlNonCanvasElementUtils.findNonCanvasElement(document, "Job", 0); + assertEquals("nightly", added.getAttribute("name")); + assertFalse(added.hasAttribute("description")); + } + + @Test + void updateNonCanvasElement_replacesAttributesAndKeepsChildren() throws Exception { + Document document = parseXml(CONFIGURATION); + + boolean updated = XmlNonCanvasElementUtils.updateNonCanvasElement(document, "Scheduler", 0, Map.of("name", "renamed")); + + assertTrue(updated); + Element scheduler = XmlNonCanvasElementUtils.findNonCanvasElement(document, "Scheduler", 0); + assertEquals("renamed", scheduler.getAttribute("name")); + assertEquals(1, scheduler.getElementsByTagName("Job").getLength()); + } + + @Test + void updateNonCanvasElement_removesClearedAttributes() throws Exception { + Document document = parseXml(CONFIGURATION); + + XmlNonCanvasElementUtils.updateNonCanvasElement(document, "Monitoring", 0, Map.of("name", "monitor")); + + Element monitoring = XmlNonCanvasElementUtils.findNonCanvasElement(document, "Monitoring", 0); + assertEquals("monitor", monitoring.getAttribute("name")); + assertFalse(monitoring.hasAttribute("enabled")); + } + + @Test + void updateNonCanvasElement_returnsFalseWhenMissing() throws Exception { + Document document = parseXml(CONFIGURATION); + + assertFalse(XmlNonCanvasElementUtils.updateNonCanvasElement(document, "Monitoring", 4, Map.of("name", "x"))); + } + + @Test + void removeNonCanvasElement_removesElement() throws Exception { + Document document = parseXml(CONFIGURATION); + + boolean removed = XmlNonCanvasElementUtils.removeNonCanvasElement(document, "Scheduler", 0); + + assertTrue(removed); + assertNull(XmlNonCanvasElementUtils.findNonCanvasElement(document, "Scheduler", 0)); + assertEquals(1, document.getElementsByTagName("Adapter").getLength()); + } + + @Test + void removeNonCanvasElement_returnsFalseWhenMissing() throws Exception { + Document document = parseXml(CONFIGURATION); + + assertFalse(XmlNonCanvasElementUtils.removeNonCanvasElement(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); + } + } +} From 7b53bbb98c0569075e6218d0d70164d763de2361 Mon Sep 17 00:00:00 2001 From: stijnpotters Date: Wed, 24 Jun 2026 16:19:52 +0200 Subject: [PATCH 2/6] Refactor drag-and-drop handling for non-canvas elements and added backend unit tests --- .../sidebars-layout/sidebar-layout.tsx | 33 +-- .../configuration-file-tile.tsx | 37 ++- .../configurations/configuration-overview.tsx | 11 +- .../non-canvas-element-context.tsx | 13 +- .../non-canvas-element-palette.tsx | 16 +- .../services/non-canvas-element-service.ts | 3 - .../NonCanvasElementControllerTest.java | 144 ++++++++++ .../NonCanvasElementServiceTest.java | 261 ++++++++++++++++++ 8 files changed, 455 insertions(+), 63 deletions(-) create mode 100644 src/test/java/org/frankframework/flow/noncanvaselement/NonCanvasElementControllerTest.java create mode 100644 src/test/java/org/frankframework/flow/noncanvaselement/NonCanvasElementServiceTest.java 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 255510b2..8b63ae19 100644 --- a/src/main/frontend/app/components/sidebars-layout/sidebar-layout.tsx +++ b/src/main/frontend/app/components/sidebars-layout/sidebar-layout.tsx @@ -41,20 +41,17 @@ export default function SidebarLayout({ if (!allotmentReady || !allotmentRef.current) return if (sizes.length === 0) return - let target = sizes.map((size, i) => (visible[i] ? size : 0)) - if (hideLeft) target = target.slice(1) - + const target = sizes.map((size, index) => (visible[index] ? size : 0)) allotmentRef.current.resize(target) - }, [sizes, visible, allotmentReady, hideLeft]) + }, [sizes, visible, allotmentReady]) const handleVisibilityChange = (index: SidebarSide, value: boolean) => { - setVisible(name, hideLeft ? index + 1 : index, value) + setVisible(name, index, value) } const saveSizes = (newSizes: number[]) => { const previous = useSidebarStore.getState().getSizes(name) ?? [] - const aligned = hideLeft ? [previous[0] ?? 0, ...newSizes] : newSizes - const merged = aligned.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')) @@ -75,18 +72,16 @@ export default function SidebarLayout({ onDragEnd={saveSizes} onVisibleChange={handleVisibilityChange} > - {!hideLeft && ( - - {childrenArray[SidebarSide.LEFT]} - - )} + + {childrenArray[SidebarSide.LEFT]} + {childrenArray[SidebarSide.MIDDLE]} 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 1a97031e..1834f997 100644 --- a/src/main/frontend/app/routes/configurations/configuration-file-tile.tsx +++ b/src/main/frontend/app/routes/configurations/configuration-file-tile.tsx @@ -12,6 +12,8 @@ import LoadingSpinner from '~/components/loading-spinner' import { NON_CANVAS_DRAG_TYPE, type NonCanvasElement } from '~/services/non-canvas-element-service' import {useRef, useState} from 'react' import { getBaseName } from '~/utils/path-utils' +import { useRef, useState, type DragEvent } from 'react' +import { type NonCanvasElement } from '~/services/non-canvas-element-service' type ConfigurationFileTileProperties = { filepath: string @@ -23,23 +25,13 @@ type ConfigurationFileTileProperties = { onAddElement: (configurationPath: string) => void onEditElement: (configurationPath: string, element: NonCanvasElement) => void onDropElement: (configurationPath: string, tagName: string) => void - dragActive?: boolean + draggedTagName?: string | null } function isRootConfiguration(relativePath: string): boolean { return relativePath.split(/[/\\]/).pop()?.toLowerCase() === 'configuration.xml' } -function isElementDrag(event: DragEvent): boolean { - return event.dataTransfer.types.includes(NON_CANVAS_DRAG_TYPE) -} - -function handleDragOver(event: DragEvent) { - if (!isElementDrag(event)) return - event.preventDefault() - event.dataTransfer.dropEffect = 'copy' -} - export default function ConfigurationFileTile({ filepath, relativePath, @@ -50,22 +42,30 @@ export default function ConfigurationFileTile({ onAddElement, onEditElement, onDropElement, - dragActive = false, + draggedTagName = null, }: Readonly) { const [showDeleteDialog, setShowDeleteDialog] = useState(false) const [isDropTarget, setIsDropTarget] = useState(false) const dragDepth = useRef(0) const navigate = useNavigate() + const isElementDrag = draggedTagName !== null + + const handleDragOver = (event: DragEvent) => { + if (!isElementDrag) return + event.preventDefault() + event.dataTransfer.dropEffect = 'copy' + } + const handleDragEnter = (event: DragEvent) => { - if (!isElementDrag(event)) return + if (!isElementDrag) return event.preventDefault() dragDepth.current += 1 setIsDropTarget(true) } - const handleDragLeave = (event: DragEvent) => { - if (!isElementDrag(event)) return + const handleDragLeave = () => { + if (!isElementDrag) return dragDepth.current -= 1 if (dragDepth.current <= 0) { dragDepth.current = 0 @@ -74,12 +74,11 @@ export default function ConfigurationFileTile({ } const handleDrop = (event: DragEvent) => { - if (!isElementDrag(event)) return + if (!draggedTagName) return event.preventDefault() dragDepth.current = 0 setIsDropTarget(false) - const tagName = event.dataTransfer.getData(NON_CANVAS_DRAG_TYPE) - if (tagName) onDropElement(filepath, tagName) + onDropElement(filepath, draggedTagName) } const handleOpenInStudio = (adapterName: string, adapterPosition: number) => { @@ -102,7 +101,7 @@ export default function ConfigurationFileTile({ let dropZoneClasses = 'border-border' if (isDropTarget) { dropZoneClasses = 'border-foreground-active ring-foreground-active border-dashed ring-2' - } else if (dragActive) { + } else if (isElementDrag) { dropZoneClasses = 'border-foreground-active/50 border-dashed' } diff --git a/src/main/frontend/app/routes/configurations/configuration-overview.tsx b/src/main/frontend/app/routes/configurations/configuration-overview.tsx index 8214f258..ff76c694 100644 --- a/src/main/frontend/app/routes/configurations/configuration-overview.tsx +++ b/src/main/frontend/app/routes/configurations/configuration-overview.tsx @@ -58,7 +58,7 @@ export default function ConfigurationOverview() { const [editor, setEditor] = useState(null) const [editorName, setEditorName] = useState('') const [addMenuConfigPath, setAddMenuConfigPath] = useState(null) - const [isDraggingFromPalette, setIsDraggingFromPalette] = useState(false) + const [draggedElementTagName, setDraggedElementTagName] = useState(null) const setSidebarVisible = useSidebarStore((state) => state.setVisible) @@ -240,7 +240,7 @@ export default function ConfigurationOverview() { return (
    - {/* Left slot is intentionally unused; hideLeft keeps it from rendering. */} + {/* Left slot is intentionally unused; hideLeft keeps its pane hidden. */} <>
    @@ -279,7 +279,7 @@ export default function ConfigurationOverview() { adapterNames={file.adapterNames} nonCanvasElements={elementsByPath[file.path] ?? []} loadingElements={loadingByPath[file.path] ?? true} - dragActive={isDraggingFromPalette} + draggedTagName={draggedElementTagName} onDelete={() => handleDelete(file.path)} onAddElement={handleAddElement} onEditElement={handleEditElement} @@ -312,7 +312,10 @@ export default function ConfigurationOverview() { onNameChange={setEditorName} /> ) : ( - + setDraggedElementTagName(null)} + /> )} diff --git a/src/main/frontend/app/routes/configurations/non-canvas-element-context.tsx b/src/main/frontend/app/routes/configurations/non-canvas-element-context.tsx index 48d4b889..44edcfcc 100644 --- a/src/main/frontend/app/routes/configurations/non-canvas-element-context.tsx +++ b/src/main/frontend/app/routes/configurations/non-canvas-element-context.tsx @@ -217,16 +217,9 @@ export default function NonCanvasElementContext({ {isSaving ? 'Saving...' : 'Save & Close'} -
    - - {mode === 'edit' && ( - - )} -
    +
    {errorMessage &&

    {errorMessage}

    } diff --git a/src/main/frontend/app/routes/configurations/non-canvas-element-palette.tsx b/src/main/frontend/app/routes/configurations/non-canvas-element-palette.tsx index 53d35806..ae5a2a75 100644 --- a/src/main/frontend/app/routes/configurations/non-canvas-element-palette.tsx +++ b/src/main/frontend/app/routes/configurations/non-canvas-element-palette.tsx @@ -4,17 +4,17 @@ 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 { getAddableNonCanvasElementNames, NON_CANVAS_DRAG_TYPE } from '~/services/non-canvas-element-service' +import { getAddableNonCanvasElementNames } from '~/services/non-canvas-element-service' /** * Palette of non-canvas elements that can be dragged onto a configuration tile to add them. - * Shares the look and drag behaviour of the studio element palette, - * but is scoped to the elements that are only allowed as direct children of a Configuration/Module. */ export default function NonCanvasElementPalette({ - onDragActiveChange, + onDragStart, + onDragEnd, }: { - onDragActiveChange?: (active: boolean) => void + onDragStart?: (tagName: string) => void + onDragEnd?: () => void }) { const { elements, isLoading } = useFFDoc() const { xsdContent } = useFrankConfigXsd() @@ -30,12 +30,12 @@ export default function NonCanvasElementPalette({ const handleSearchChange = (event: ChangeEvent) => setSearch(event.target.value) const handleDragStart = (tagName: string) => (event: DragEvent) => { - event.dataTransfer.setData(NON_CANVAS_DRAG_TYPE, tagName) + event.dataTransfer.setData('text/plain', tagName) event.dataTransfer.effectAllowed = 'copy' - onDragActiveChange?.(true) + onDragStart?.(tagName) } - const handleDragEnd = () => onDragActiveChange?.(false) + const handleDragEnd = () => onDragEnd?.() return (
    diff --git a/src/main/frontend/app/services/non-canvas-element-service.ts b/src/main/frontend/app/services/non-canvas-element-service.ts index d47aca21..5cbc39d2 100644 --- a/src/main/frontend/app/services/non-canvas-element-service.ts +++ b/src/main/frontend/app/services/non-canvas-element-service.ts @@ -11,9 +11,6 @@ export interface NonCanvasElement { const ROOT_TYPE_CANDIDATES = ['ConfigurationType', 'ModuleType', 'Configuration', 'Module'] -/** MIME-like key used to carry a non-canvas element tag name across a drag-and-drop onto a configuration tile. */ -export const NON_CANVAS_DRAG_TYPE = 'application/x-noncanvas-element' - function getBaseUrl(projectName: string): string { return `/projects/${encodeURIComponent(projectName)}/non-canvas-elements` } diff --git a/src/test/java/org/frankframework/flow/noncanvaselement/NonCanvasElementControllerTest.java b/src/test/java/org/frankframework/flow/noncanvaselement/NonCanvasElementControllerTest.java new file mode 100644 index 00000000..a2e16632 --- /dev/null +++ b/src/test/java/org/frankframework/flow/noncanvaselement/NonCanvasElementControllerTest.java @@ -0,0 +1,144 @@ +package org.frankframework.flow.noncanvaselement; + +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(NonCanvasElementController.class) +@AutoConfigureMockMvc(addFilters = false) +class NonCanvasElementControllerTest { + + @Autowired + private MockMvc mockMvc; + + @MockitoBean + private NonCanvasElementService nonCanvasElementService; + + private static final String PROJECT = "FrankFlowTestProject"; + private static final String BASE_URL = "/api/projects/" + PROJECT + "/non-canvas-elements"; + + @Test + void getNonCanvasElements_returnsList() throws Exception { + NonCanvasElementDTO element = new NonCanvasElementDTO("Monitoring", "monitor", 0, Map.of("enabled", "true")); + when(nonCanvasElementService.getNonCanvasElements("config.xml")).thenReturn(List.of(element)); + + 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(nonCanvasElementService).getNonCanvasElements("config.xml"); + } + + @Test + void getNonCanvasElements_missingConfigurationPath_returns400() throws Exception { + mockMvc.perform(get(BASE_URL).accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isBadRequest()); + } + + @Test + void getNonCanvasElements_serviceThrowsNotFound_returns404() throws Exception { + when(nonCanvasElementService.getNonCanvasElements("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 addNonCanvasElement_deserializesBodyAndReturnsUpdatedList() throws Exception { + NonCanvasElementDTO element = new NonCanvasElementDTO("Scheduler", "daily", 0, Map.of("name", "daily")); + when(nonCanvasElementService.addNonCanvasElement("config.xml", "Scheduler", Map.of("name", "daily"))) + .thenReturn(List.of(element)); + + 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(nonCanvasElementService).addNonCanvasElement("config.xml", "Scheduler", Map.of("name", "daily")); + } + + @Test + void updateNonCanvasElement_deserializesBodyAndReturnsUpdatedList() throws Exception { + NonCanvasElementDTO element = new NonCanvasElementDTO("Monitoring", "monitor", 0, Map.of("enabled", "false")); + when(nonCanvasElementService.updateNonCanvasElement("config.xml", "Monitoring", 0, Map.of("enabled", "false"))) + .thenReturn(List.of(element)); + + 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(nonCanvasElementService).updateNonCanvasElement("config.xml", "Monitoring", 0, Map.of("enabled", "false")); + } + + @Test + void updateNonCanvasElement_serviceThrowsNotFound_returns404() throws Exception { + when(nonCanvasElementService.updateNonCanvasElement(anyString(), anyString(), anyInt(), anyMap())) + .thenThrow(new ApiException("Non-canvas element 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 element not found: Monitoring")); + } + + @Test + void deleteNonCanvasElement_returnsRemainingList() throws Exception { + when(nonCanvasElementService.deleteNonCanvasElement("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(nonCanvasElementService).deleteNonCanvasElement("config.xml", "Scheduler", 0); + } + + @Test + void deleteNonCanvasElement_serviceThrowsNotFound_returns404() throws Exception { + when(nonCanvasElementService.deleteNonCanvasElement("config.xml", "Scheduler", 9)) + .thenThrow(new ApiException("Non-canvas element 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 element not found: Scheduler")); + } +} diff --git a/src/test/java/org/frankframework/flow/noncanvaselement/NonCanvasElementServiceTest.java b/src/test/java/org/frankframework/flow/noncanvaselement/NonCanvasElementServiceTest.java new file mode 100644 index 00000000..6115ab4e --- /dev/null +++ b/src/test/java/org/frankframework/flow/noncanvaselement/NonCanvasElementServiceTest.java @@ -0,0 +1,261 @@ +package org.frankframework.flow.noncanvaselement; + +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 NonCanvasElementServiceTest { + + @Mock + private FileSystemStorage fileSystemStorage; + + private NonCanvasElementService nonCanvasElementService; + + @TempDir + Path tempDir; + + private static final String CONFIGURATION = """ + + + + + + + + + + + + """; + + @BeforeEach + void setUp() { + nonCanvasElementService = new NonCanvasElementService(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 -> Files.readString(Path.of(invocation.getArgument(0)), StandardCharsets.UTF_8)); + } + + private void stubWriteFile() throws IOException { + doAnswer(invocation -> { + Path filePath = Path.of(invocation.getArgument(0)); + Files.writeString(filePath, invocation.getArgument(1), 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 getNonCanvasElements_returnsDirectChildrenExcludingAdapters() throws Exception { + stubToAbsolutePath(); + stubReadFile(); + Path file = writeConfiguration(CONFIGURATION); + + List elements = nonCanvasElementService.getNonCanvasElements(file.toString()); + + assertEquals(2, elements.size()); + assertEquals("Scheduler", elements.get(0).tagName()); + assertNull(elements.get(0).name()); + assertEquals("Monitoring", elements.get(1).tagName()); + assertEquals("monitor", elements.get(1).name()); + assertEquals("true", elements.get(1).attributes().get("enabled")); + } + + @Test + void getNonCanvasElements_assignsOccurrenceIndexPerTagName() throws Exception { + stubToAbsolutePath(); + stubReadFile(); + Path file = writeConfiguration(""" + + + + + + """); + + List elements = nonCanvasElementService.getNonCanvasElements(file.toString()); + + assertEquals(3, elements.size()); + assertEquals(0, elements.get(0).index()); + assertEquals("first", elements.get(0).name()); + assertEquals(0, elements.get(1).index()); + assertEquals(1, elements.get(2).index()); + assertEquals("second", elements.get(2).name()); + } + + @Test + void getNonCanvasElements_blankPath_throwsBadRequest() { + ApiException exception = + assertThrows(ApiException.class, () -> nonCanvasElementService.getNonCanvasElements(" ")); + assertEquals(HttpStatus.BAD_REQUEST, exception.getStatus()); + } + + @Test + void getNonCanvasElements_fileNotFound_throwsNotFound() { + stubToAbsolutePath(); + String path = tempDir.resolve("missing.xml").toString(); + + ApiException exception = + assertThrows(ApiException.class, () -> nonCanvasElementService.getNonCanvasElements(path)); + assertEquals(HttpStatus.NOT_FOUND, exception.getStatus()); + } + + @Test + void getNonCanvasElements_pathIsDirectory_throwsNotFound() throws IOException { + stubToAbsolutePath(); + Path directory = Files.createDirectory(tempDir.resolve("subdir")); + + ApiException exception = + assertThrows(ApiException.class, () -> nonCanvasElementService.getNonCanvasElements(directory.toString())); + assertEquals(HttpStatus.NOT_FOUND, exception.getStatus()); + } + + @Test + void getNonCanvasElements_malformedXml_throwsBadRequest() throws Exception { + stubToAbsolutePath(); + stubReadFile(); + Path file = writeConfiguration(""); + + ApiException exception = + assertThrows(ApiException.class, () -> nonCanvasElementService.getNonCanvasElements(file.toString())); + assertEquals(HttpStatus.BAD_REQUEST, exception.getStatus()); + } + + @Test + void addNonCanvasElement_appendsElementAndPersists() throws Exception { + stubToAbsolutePath(); + stubReadFile(); + stubWriteFile(); + Path file = writeConfiguration(""); + + List elements = nonCanvasElementService.addNonCanvasElement( + file.toString(), "Monitoring", Map.of("name", "monitor", "enabled", "true")); + + assertEquals(1, elements.size()); + assertEquals("Monitoring", elements.getFirst().tagName()); + assertEquals("monitor", elements.getFirst().name()); + + verify(fileSystemStorage).writeFile(eq(file.toString()), anyString()); + String persisted = Files.readString(file, StandardCharsets.UTF_8); + assertTrue(persisted.contains(" nonCanvasElementService.addNonCanvasElement("configuration.xml", " ", Map.of())); + + assertEquals(HttpStatus.BAD_REQUEST, exception.getStatus()); + verify(fileSystemStorage, never()).writeFile(anyString(), anyString()); + } + + @Test + void updateNonCanvasElement_replacesAttributesAndPersists() throws Exception { + stubToAbsolutePath(); + stubReadFile(); + stubWriteFile(); + Path file = writeConfiguration(""" + + + + """); + + List elements = nonCanvasElementService.updateNonCanvasElement( + file.toString(), "Monitoring", 0, Map.of("name", "monitor", "enabled", "false")); + + assertEquals(1, elements.size()); + assertEquals("false", elements.getFirst().attributes().get("enabled")); + assertTrue(Files.readString(file, StandardCharsets.UTF_8).contains("enabled=\"false\"")); + } + + @Test + void updateNonCanvasElement_notFound_throwsNotFoundAndDoesNotWrite() throws Exception { + stubToAbsolutePath(); + stubReadFile(); + Path file = writeConfiguration(""); + + ApiException exception = assertThrows( + ApiException.class, + () -> nonCanvasElementService.updateNonCanvasElement(file.toString(), "Scheduler", 0, Map.of("name", "x"))); + + assertEquals(HttpStatus.NOT_FOUND, exception.getStatus()); + verify(fileSystemStorage, never()).writeFile(anyString(), anyString()); + } + + @Test + void deleteNonCanvasElement_removesElementAndPersists() throws Exception { + stubToAbsolutePath(); + stubReadFile(); + stubWriteFile(); + Path file = writeConfiguration(""" + + + + + """); + + List elements = nonCanvasElementService.deleteNonCanvasElement(file.toString(), "Scheduler", 0); + + assertEquals(1, elements.size()); + assertEquals("Monitoring", elements.getFirst().tagName()); + assertFalse(Files.readString(file, StandardCharsets.UTF_8).contains(""); + + ApiException exception = assertThrows( + ApiException.class, + () -> nonCanvasElementService.deleteNonCanvasElement(file.toString(), "Scheduler", 0)); + + assertEquals(HttpStatus.NOT_FOUND, exception.getStatus()); + verify(fileSystemStorage, never()).writeFile(anyString(), anyString()); + } +} From 3aa125055123ae4210472250727c294fc1d69c72 Mon Sep 17 00:00:00 2001 From: stijnpotters Date: Wed, 24 Jun 2026 16:46:16 +0200 Subject: [PATCH 3/6] Refactor file reading stub in NonCanvasElementServiceTest for improved clarity --- .../noncanvaselement/NonCanvasElementServiceTest.java | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/src/test/java/org/frankframework/flow/noncanvaselement/NonCanvasElementServiceTest.java b/src/test/java/org/frankframework/flow/noncanvaselement/NonCanvasElementServiceTest.java index 6115ab4e..5b3f7b92 100644 --- a/src/test/java/org/frankframework/flow/noncanvaselement/NonCanvasElementServiceTest.java +++ b/src/test/java/org/frankframework/flow/noncanvaselement/NonCanvasElementServiceTest.java @@ -67,14 +67,17 @@ private void stubToAbsolutePath() { } private void stubReadFile() throws IOException { - when(fileSystemStorage.readFile(anyString())) - .thenAnswer(invocation -> Files.readString(Path.of(invocation.getArgument(0)), StandardCharsets.UTF_8)); + 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 -> { - Path filePath = Path.of(invocation.getArgument(0)); - Files.writeString(filePath, invocation.getArgument(1), StandardCharsets.UTF_8); + String path = invocation.getArgument(0); + String content = invocation.getArgument(1); + Files.writeString(Path.of(path), content, StandardCharsets.UTF_8); return null; }) .when(fileSystemStorage) From 8bbd61f99a1a71fc3d1a72ad37aa348c1297951b Mon Sep 17 00:00:00 2001 From: stijnpotters Date: Mon, 29 Jun 2026 15:06:48 +0200 Subject: [PATCH 4/6] Add DTOs and service for non-canvas component management with controller integration and improved frontend according to feedback --- .../routes/configurations/adapter-context.tsx | 102 +++++++++++ ....tsx => add-non-canvas-component-menu.tsx} | 18 +- .../configuration-file-tile.tsx | 161 ++++++++++++------ .../configurations/configuration-overview.tsx | 139 ++++++++++----- ...t.tsx => non-canvas-component-context.tsx} | 74 ++++---- ...e.tsx => non-canvas-component-palette.tsx} | 14 +- .../use-non-canvas-components.ts | 50 ++++++ .../configurations/use-non-canvas-elements.ts | 47 ----- .../services/non-canvas-component-service.ts | 106 ++++++++++++ .../services/non-canvas-element-service.ts | 75 -------- .../NonCanvasComponentController.java | 51 ++++++ .../NonCanvasComponentCreateDTO.java | 5 + .../NonCanvasComponentDTO.java | 5 + .../NonCanvasComponentService.java} | 42 ++--- .../NonCanvasComponentUpdateDTO.java | 5 + .../NonCanvasElementController.java | 51 ------ .../NonCanvasElementCreateDTO.java | 5 - .../noncanvaselement/NonCanvasElementDTO.java | 5 - .../NonCanvasElementUpdateDTO.java | 5 - ...s.java => XmlNonCanvasComponentUtils.java} | 26 +-- .../NonCanvasComponentControllerTest.java} | 66 +++---- .../NonCanvasComponentServiceTest.java} | 94 +++++----- ...va => XmlNonCanvasComponentUtilsTest.java} | 72 ++++---- 23 files changed, 735 insertions(+), 483 deletions(-) create mode 100644 src/main/frontend/app/routes/configurations/adapter-context.tsx rename src/main/frontend/app/routes/configurations/{add-non-canvas-element-menu.tsx => add-non-canvas-component-menu.tsx} (82%) rename src/main/frontend/app/routes/configurations/{non-canvas-element-context.tsx => non-canvas-component-context.tsx} (75%) rename src/main/frontend/app/routes/configurations/{non-canvas-element-palette.tsx => non-canvas-component-palette.tsx} (79%) create mode 100644 src/main/frontend/app/routes/configurations/use-non-canvas-components.ts delete mode 100644 src/main/frontend/app/routes/configurations/use-non-canvas-elements.ts create mode 100644 src/main/frontend/app/services/non-canvas-component-service.ts delete mode 100644 src/main/frontend/app/services/non-canvas-element-service.ts create mode 100644 src/main/java/org/frankframework/flow/noncanvascomponent/NonCanvasComponentController.java create mode 100644 src/main/java/org/frankframework/flow/noncanvascomponent/NonCanvasComponentCreateDTO.java create mode 100644 src/main/java/org/frankframework/flow/noncanvascomponent/NonCanvasComponentDTO.java rename src/main/java/org/frankframework/flow/{noncanvaselement/NonCanvasElementService.java => noncanvascomponent/NonCanvasComponentService.java} (67%) create mode 100644 src/main/java/org/frankframework/flow/noncanvascomponent/NonCanvasComponentUpdateDTO.java delete mode 100644 src/main/java/org/frankframework/flow/noncanvaselement/NonCanvasElementController.java delete mode 100644 src/main/java/org/frankframework/flow/noncanvaselement/NonCanvasElementCreateDTO.java delete mode 100644 src/main/java/org/frankframework/flow/noncanvaselement/NonCanvasElementDTO.java delete mode 100644 src/main/java/org/frankframework/flow/noncanvaselement/NonCanvasElementUpdateDTO.java rename src/main/java/org/frankframework/flow/utility/{XmlNonCanvasElementUtils.java => XmlNonCanvasComponentUtils.java} (78%) rename src/test/java/org/frankframework/flow/{noncanvaselement/NonCanvasElementControllerTest.java => noncanvascomponent/NonCanvasComponentControllerTest.java} (58%) rename src/test/java/org/frankframework/flow/{noncanvaselement/NonCanvasElementServiceTest.java => noncanvascomponent/NonCanvasComponentServiceTest.java} (62%) rename src/test/java/org/frankframework/flow/utility/{XmlNonCanvasElementUtilsTest.java => XmlNonCanvasComponentUtilsTest.java} (50%) 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..6969e859 --- /dev/null +++ b/src/main/frontend/app/routes/configurations/adapter-context.tsx @@ -0,0 +1,102 @@ +import { useEffect, useState } from 'react' +import Button from '~/components/inputs/button' +import Input from '~/components/inputs/input' +import { deleteAdapter, renameAdapter } from '~/services/adapter-service' + +export interface AdapterEditorState { + configPath: string + adapterName: string + adapterPosition: number +} + +interface 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)} + /> +
    +
    +
    + +
    +
    + + + +
    + + {errorMessage &&

    {errorMessage}

    } +
    +
    + ) +} diff --git a/src/main/frontend/app/routes/configurations/add-non-canvas-element-menu.tsx b/src/main/frontend/app/routes/configurations/add-non-canvas-component-menu.tsx similarity index 82% rename from src/main/frontend/app/routes/configurations/add-non-canvas-element-menu.tsx rename to src/main/frontend/app/routes/configurations/add-non-canvas-component-menu.tsx index c470157e..cddb87f1 100644 --- a/src/main/frontend/app/routes/configurations/add-non-canvas-element-menu.tsx +++ b/src/main/frontend/app/routes/configurations/add-non-canvas-component-menu.tsx @@ -4,25 +4,25 @@ 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 { getAddableNonCanvasElementNames } from '~/services/non-canvas-element-service' +import { getAddableNonCanvasComponentNames } from '~/services/non-canvas-component-service' -interface AddNonCanvasElementMenuProperties { +interface AddNonCanvasComponentMenuProperties { isOpen: boolean onClose: () => void onSelect: (tagName: string) => void } -export default function AddNonCanvasElementMenu({ +export default function AddNonCanvasComponentMenu({ isOpen, onClose, onSelect, -}: Readonly) { +}: Readonly) { const { elements } = useFFDoc() const { xsdContent } = useFrankConfigXsd() const [search, setSearch] = useState('') const [selected, setSelected] = useState(null) - const addableNames = useMemo(() => getAddableNonCanvasElementNames(xsdContent, elements), [xsdContent, elements]) + const addableNames = useMemo(() => getAddableNonCanvasComponentNames(xsdContent, elements), [xsdContent, elements]) const filteredNames = useMemo( () => addableNames.filter((name) => name.toLowerCase().includes(search.toLowerCase())), @@ -65,9 +65,9 @@ export default function AddNonCanvasElementMenu({ × -

    Add non-canvas element

    +

    Add non-canvas component

    - +
      {filteredNames.length > 0 ? ( @@ -88,14 +88,14 @@ export default function AddNonCanvasElementMenu({ }) ) : (
    • - {addableNames.length === 0 ? 'No addable elements found.' : 'No results found.'} + {addableNames.length === 0 ? 'No addable components found.' : 'No results found.'}
    • )}
    , 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 1834f997..6efebbb1 100644 --- a/src/main/frontend/app/routes/configurations/configuration-file-tile.tsx +++ b/src/main/frontend/app/routes/configurations/configuration-file-tile.tsx @@ -2,13 +2,14 @@ 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 TuningIcon from '/icons/solar/Tuning.svg?react' import { useNavigate } from 'react-router' 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 LoadingSpinner from '~/components/loading-spinner' +import { useRef, useState, type DragEvent, type ReactNode } from 'react' +import { type NonCanvasComponent } from '~/services/non-canvas-component-service' import { NON_CANVAS_DRAG_TYPE, type NonCanvasElement } from '~/services/non-canvas-element-service' import {useRef, useState} from 'react' import { getBaseName } from '~/utils/path-utils' @@ -19,29 +20,57 @@ type ConfigurationFileTileProperties = { filepath: string relativePath: string adapterNames: string[] - nonCanvasElements: NonCanvasElement[] - loadingElements: boolean + nonCanvasComponents: NonCanvasComponent[] + loadingComponents: boolean onDelete: () => Promise - onAddElement: (configurationPath: string) => void - onEditElement: (configurationPath: string, element: NonCanvasElement) => void - onDropElement: (configurationPath: string, tagName: string) => void + 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 } +interface ComponentRowProperties { + typeLabel: string | null + primaryLabel: string + onConfigure: () => void + action?: ReactNode +} + +interface AdapterListItemProperties { + adapterName: string + adapterPosition: number + onConfigure: () => void + onOpenInStudio: (adapterName: string, adapterPosition: number) => void +} +interface ComponentListItemProperties { + component: NonCanvasComponent + onConfigure: () => void +} + function isRootConfiguration(relativePath: string): boolean { return relativePath.split(/[/\\]/).pop()?.toLowerCase() === 'configuration.xml' } +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 ConfigurationFileTile({ filepath, relativePath, adapterNames, - nonCanvasElements, - loadingElements, + nonCanvasComponents, + loadingComponents, onDelete, - onAddElement, - onEditElement, - onDropElement, + onAddComponent, + onEditComponent, + onConfigureAdapter, + onDropComponent, draggedTagName = null, }: Readonly) { const [showDeleteDialog, setShowDeleteDialog] = useState(false) @@ -49,23 +78,23 @@ export default function ConfigurationFileTile({ const dragDepth = useRef(0) const navigate = useNavigate() - const isElementDrag = draggedTagName !== null + const isComponentDrag = draggedTagName !== null const handleDragOver = (event: DragEvent) => { - if (!isElementDrag) return + if (!isComponentDrag) return event.preventDefault() event.dataTransfer.dropEffect = 'copy' } const handleDragEnter = (event: DragEvent) => { - if (!isElementDrag) return + if (!isComponentDrag) return event.preventDefault() dragDepth.current += 1 setIsDropTarget(true) } const handleDragLeave = () => { - if (!isElementDrag) return + if (!isComponentDrag) return dragDepth.current -= 1 if (dragDepth.current <= 0) { dragDepth.current = 0 @@ -78,7 +107,7 @@ export default function ConfigurationFileTile({ event.preventDefault() dragDepth.current = 0 setIsDropTarget(false) - onDropElement(filepath, draggedTagName) + onDropComponent(filepath, draggedTagName) } const handleOpenInStudio = (adapterName: string, adapterPosition: number) => { @@ -96,44 +125,45 @@ export default function ConfigurationFileTile({ setShowDeleteDialog(false) } - const hasContent = adapterNames.length > 0 || nonCanvasElements.length > 0 + 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 (isElementDrag) { + } else if (isComponentDrag) { dropZoneClasses = 'border-foreground-active/50 border-dashed' } - let elementList - if (loadingElements && adapterNames.length === 0) { - elementList = ( + let componentList + if (loadingComponents && adapterNames.length === 0) { + componentList = (
    ) } else if (hasContent) { - elementList = ( + componentList = (
      {adapterNames.map((adapterName, adapterPosition) => ( onConfigureAdapter(filepath, adapterName, adapterPosition)} onOpenInStudio={handleOpenInStudio} /> ))} - {nonCanvasElements.map((element) => ( - onEditElement(filepath, element)} + {nonCanvasComponents.map((component) => ( + onEditComponent(filepath, component)} /> ))}
    ) } else { - elementList =
    No adapters or elements found
    + componentList =
    No adapters or components found
    } return ( @@ -146,7 +176,7 @@ export default function ConfigurationFileTile({ > {isDropTarget && (
    - Drop to add element + Drop to add component
    )}
    @@ -166,8 +196,10 @@ export default function ConfigurationFileTile({
    -

    Adapters & elements

    -
    {elementList}
    +

    + Adapters & components +

    +
    {componentList}
    @@ -178,8 +210,8 @@ export default function ConfigurationFileTile({ /> } - label="Add non-canvas element" - onClick={() => onAddElement(filepath)} + label="Add non-canvas component" + onClick={() => onAddComponent(filepath)} />
    @@ -201,34 +233,55 @@ type AdapterListItemProperties = { onOpenInStudio: (adapterName: string, adapterPosition: number) => void } -function AdapterListItem({ adapterName, adapterPosition, onOpenInStudio }: Readonly) { +function ComponentRow({ typeLabel, primaryLabel, onConfigure, action }: Readonly) { return ( -
  • - - {adapterName} - - } - label="Open in Studio" - onClick={() => onOpenInStudio(adapterName, adapterPosition)} - /> +
  • + + {action &&
    {action}
    }
  • ) } -type NonCanvasElementListItemProperties = { +function AdapterListItem({ + adapterName, + adapterPosition, + onConfigure, + onOpenInStudio, +}: Readonly) { + return ( + } + label="Open in Studio" + onClick={() => onOpenInStudio(adapterName, adapterPosition)} + /> + } + /> + ) +} + +type NonCanvasComponentListItemProperties = { element: NonCanvasElement onConfigure: () => void } -function NonCanvasElementListItem({ element, onConfigure }: Readonly) { - const label = element.name ? `${element.tagName} · ${element.name}` : element.tagName - return ( -
  • - - {label} - - } label="Configure" onClick={onConfigure} /> -
  • - ) +function ComponentListItem({ component, onConfigure }: Readonly) { + const { typeLabel, primaryLabel } = getComponentLabels(component) + return } diff --git a/src/main/frontend/app/routes/configurations/configuration-overview.tsx b/src/main/frontend/app/routes/configurations/configuration-overview.tsx index ff76c694..e54f3448 100644 --- a/src/main/frontend/app/routes/configurations/configuration-overview.tsx +++ b/src/main/frontend/app/routes/configurations/configuration-overview.tsx @@ -15,11 +15,12 @@ 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 NonCanvasElementContext, { type NonCanvasEditorState } from './non-canvas-element-context' -import NonCanvasElementPalette from './non-canvas-element-palette' -import AddNonCanvasElementMenu from './add-non-canvas-element-menu' -import type { NonCanvasElement } from '~/services/non-canvas-element-service' -import { useNonCanvasElements } from './use-non-canvas-elements' +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' const SIDEBAR_NAME = 'configuration-overview-v2' import { relativeTo } from '~/utils/path-utils' @@ -55,15 +56,17 @@ export default function ConfigurationOverview() { const [isLoading, setIsLoading] = useState(false) const [searchQuery, setSearchQuery] = useState('') const [debouncedQuery, setDebouncedQuery] = useState(searchQuery) - const [editor, setEditor] = useState(null) + const [editor, setEditor] = useState(null) + const [adapterEditor, setAdapterEditor] = useState(null) const [editorName, setEditorName] = useState('') const [addMenuConfigPath, setAddMenuConfigPath] = useState(null) - const [draggedElementTagName, setDraggedElementTagName] = useState(null) + const [draggedComponentTagName, setDraggedComponentTagName] = useState(null) const setSidebarVisible = useSidebarStore((state) => state.setVisible) const openEditor = useCallback( - (state: NonCanvasEditorState) => { + (state: NonCanvasComponentEditorState) => { + setAdapterEditor(null) setEditor(state) setEditorName(state.initialAttributes?.name ?? '') setSidebarVisible(SIDEBAR_NAME, SidebarSide.RIGHT, true) @@ -71,16 +74,27 @@ export default function ConfigurationOverview() { [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 handleAddElement = useCallback((configPath: string) => { + const handleAddComponent = useCallback((configPath: string) => { setAddMenuConfigPath(configPath) }, []) - const handleSelectElementType = useCallback( + const handleSelectComponentType = useCallback( (tagName: string) => { if (!addMenuConfigPath) return openEditor({ mode: 'add', configPath: addMenuConfigPath, tagName }) @@ -89,20 +103,27 @@ export default function ConfigurationOverview() { [addMenuConfigPath, openEditor], ) - const handleEditElement = useCallback( - (configurationPath: string, element: NonCanvasElement) => { + const handleEditComponent = useCallback( + (configurationPath: string, component: NonCanvasComponent) => { openEditor({ mode: 'edit', configPath: configurationPath, - tagName: element.tagName, - index: element.index, - initialAttributes: element.attributes, + tagName: component.tagName, + index: component.index, + initialAttributes: component.attributes, }) }, [openEditor], ) - const handleDropElement = useCallback( + 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 }) }, @@ -196,19 +217,24 @@ export default function ConfigurationOverview() { }, [allConfigFiles, debouncedQuery]) const configurationPaths = useMemo(() => allConfigFiles.map((file) => file.path), [allConfigFiles]) - const { elementsByPath, loadingByPath, replaceElements } = useNonCanvasElements( + const { componentsByPath, loadingByPath, replaceComponents } = useNonCanvasComponents( currentConfigurationProject?.name ?? '', configurationPaths, ) - const handleElementSaved = useCallback( - (configurationPath: string, elements: NonCanvasElement[]) => { - replaceElements(configurationPath, elements) + const handleComponentSaved = useCallback( + (configurationPath: string, components: NonCanvasComponent[]) => { + replaceComponents(configurationPath, components) closeEditor() }, - [replaceElements, closeEditor], + [replaceComponents, closeEditor], ) + const handleAdapterChanged = useCallback(() => { + closeEditor() + loadTree() + }, [closeEditor, loadTree]) + if (!currentConfigurationProject) { return (
    @@ -228,8 +254,10 @@ export default function ConfigurationOverview() { ) } - let sidebarTitle = 'Elements' - if (editor) { + let sidebarTitle = 'Components' + if (adapterEditor) { + sidebarTitle = editorName ? `Adapter · ${editorName}` : 'Adapter' + } else if (editor) { if (editorName) { sidebarTitle = `${editor.tagName} · ${editorName}` } else { @@ -237,6 +265,38 @@ export default function ConfigurationOverview() { } } + let sidebarContent + if (adapterEditor) { + sidebarContent = ( + + ) + } else if (editor) { + sidebarContent = ( + handleComponentSaved(editor.configPath, components)} + onClose={closeEditor} + onNameChange={setEditorName} + /> + ) + } else { + sidebarContent = ( + setDraggedComponentTagName(null)} + /> + ) + } + return (
    @@ -277,13 +337,14 @@ export default function ConfigurationOverview() { filepath={file.path} relativePath={file.relativePath} adapterNames={file.adapterNames} - nonCanvasElements={elementsByPath[file.path] ?? []} - loadingElements={loadingByPath[file.path] ?? true} - draggedTagName={draggedElementTagName} + nonCanvasComponents={componentsByPath[file.path] ?? []} + loadingComponents={loadingByPath[file.path] ?? true} + draggedTagName={draggedComponentTagName} onDelete={() => handleDelete(file.path)} - onAddElement={handleAddElement} - onEditElement={handleEditElement} - onDropElement={handleDropElement} + onAddComponent={handleAddComponent} + onEditComponent={handleEditComponent} + onConfigureAdapter={handleConfigureAdapter} + onDropComponent={handleDropComponent} /> ))} @@ -302,28 +363,14 @@ export default function ConfigurationOverview() { <> - {editor ? ( - handleElementSaved(editor.configPath, elements)} - onClose={closeEditor} - onNameChange={setEditorName} - /> - ) : ( - setDraggedElementTagName(null)} - /> - )} + {sidebarContent} - setAddMenuConfigPath(null)} - onSelect={handleSelectElementType} + onSelect={handleSelectComponentType} />
    ) diff --git a/src/main/frontend/app/routes/configurations/non-canvas-element-context.tsx b/src/main/frontend/app/routes/configurations/non-canvas-component-context.tsx similarity index 75% rename from src/main/frontend/app/routes/configurations/non-canvas-element-context.tsx rename to src/main/frontend/app/routes/configurations/non-canvas-component-context.tsx index 44edcfcc..b28dfefb 100644 --- a/src/main/frontend/app/routes/configurations/non-canvas-element-context.tsx +++ b/src/main/frontend/app/routes/configurations/non-canvas-component-context.tsx @@ -1,17 +1,21 @@ import { useCallback, useEffect, useMemo, useState } from 'react' import { useFFDoc } from '@frankframework/doc-library-react' -import type { Attribute } from '@frankframework/doc-library-core' +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 LoadingSpinner from '~/components/loading-spinner' import { - addNonCanvasElement, - deleteNonCanvasElement, - updateNonCanvasElement, - type NonCanvasElement, -} from '~/services/non-canvas-element-service' + addNonCanvasComponent, + deleteNonCanvasComponent, + updateNonCanvasComponent, + type NonCanvasComponent, +} from '~/services/non-canvas-component-service' -export interface NonCanvasEditorState { +export interface NonCanvasComponentEditorState { mode: 'add' | 'edit' configPath: string tagName: string @@ -19,23 +23,23 @@ export interface NonCanvasEditorState { initialAttributes?: Record } -interface NonCanvasElementContextProperties { +interface NonCanvasComponentContextProperties { projectName: string - editor: NonCanvasEditorState - onSaved: (elements: NonCanvasElement[]) => void + editor: NonCanvasComponentEditorState + onSaved: (components: NonCanvasComponent[]) => void onClose: () => void onNameChange?: (name: string) => void } const EMPTY_ATTRIBUTE: Attribute = {} -export default function NonCanvasElementContext({ +export default function NonCanvasComponentContext({ projectName, editor, onSaved, onClose, onNameChange, -}: Readonly) { +}: Readonly) { const { elements, ffDoc, isLoading } = useFFDoc() const { mode, configPath, tagName, index, initialAttributes } = editor @@ -45,10 +49,20 @@ export default function NonCanvasElementContext({ const [isSaving, setIsSaving] = useState(false) const [errorMessage, setErrorMessage] = useState('') - const attributeDefinitions = useMemo>( - () => elements?.[tagName]?.attributes ?? {}, - [elements, tagName], - ) + 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)) @@ -86,11 +100,11 @@ export default function NonCanvasElementContext({ return mandatoryFilled && integersValid }, [fieldKeys, attributeDefinitions, inputValues]) - const elementName = inputValues['name']?.trim() ?? '' + const componentName = inputValues['name']?.trim() ?? '' useEffect(() => { - onNameChange?.(elementName) - }, [elementName, onNameChange]) + onNameChange?.(componentName) + }, [componentName, onNameChange]) const makeEnumOptions = useCallback( (attribute: Attribute) => { @@ -110,12 +124,14 @@ export default function NonCanvasElementContext({ 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) } - return { baseKeys: [...mandatory, ...filled], restKeys: rest } + const leading = fieldKeys.includes('name') ? ['name'] : [] + return { baseKeys: [...leading, ...mandatory, ...filled], restKeys: rest } }, [fieldKeys, attributeDefinitions, initiallyFilledKeys]) const orderedKeys = showAll ? [...baseKeys, ...restKeys] : baseKeys @@ -141,10 +157,10 @@ export default function NonCanvasElementContext({ const attributes = resolveFilledAttributes() const pendingSave = mode === 'add' - ? addNonCanvasElement(projectName, configPath, tagName, attributes) - : updateNonCanvasElement(projectName, configPath, tagName, index ?? 0, attributes) - const updatedElements = await pendingSave - onSaved(updatedElements) + ? 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 { @@ -157,8 +173,8 @@ export default function NonCanvasElementContext({ setErrorMessage('') try { - const updatedElements = await deleteNonCanvasElement(projectName, configPath, tagName, index ?? 0) - onSaved(updatedElements) + 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) @@ -168,7 +184,7 @@ export default function NonCanvasElementContext({ if (isLoading || !elements) { return (
    - +
    ) } @@ -177,11 +193,11 @@ export default function NonCanvasElementContext({
    {tagName}
    - {elementName &&

    {elementName}

    } + {componentName &&

    {componentName}

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

    This element has no configurable attributes.

    +

    This component has no configurable attributes.

    )} {orderedKeys.map((key) => ( diff --git a/src/main/frontend/app/routes/configurations/non-canvas-element-palette.tsx b/src/main/frontend/app/routes/configurations/non-canvas-component-palette.tsx similarity index 79% rename from src/main/frontend/app/routes/configurations/non-canvas-element-palette.tsx rename to src/main/frontend/app/routes/configurations/non-canvas-component-palette.tsx index ae5a2a75..e6cd1104 100644 --- a/src/main/frontend/app/routes/configurations/non-canvas-element-palette.tsx +++ b/src/main/frontend/app/routes/configurations/non-canvas-component-palette.tsx @@ -4,12 +4,12 @@ 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 { getAddableNonCanvasElementNames } from '~/services/non-canvas-element-service' +import { getAddableNonCanvasComponentNames } from '~/services/non-canvas-component-service' /** - * Palette of non-canvas elements that can be dragged onto a configuration tile to add them. + * Palette of non-canvas components that can be dragged onto a configuration tile to add them. */ -export default function NonCanvasElementPalette({ +export default function NonCanvasComponentPalette({ onDragStart, onDragEnd, }: { @@ -20,7 +20,7 @@ export default function NonCanvasElementPalette({ const { xsdContent } = useFrankConfigXsd() const [search, setSearch] = useState('') - const addableNames = useMemo(() => getAddableNonCanvasElementNames(xsdContent, elements), [xsdContent, elements]) + const addableNames = useMemo(() => getAddableNonCanvasComponentNames(xsdContent, elements), [xsdContent, elements]) const filteredNames = useMemo( () => addableNames.filter((name) => name.toLowerCase().includes(search.toLowerCase())), @@ -39,17 +39,17 @@ export default function NonCanvasElementPalette({ return (
    - + {isLoading || !elements || !xsdContent ? (
    - +
    ) : (
      {filteredNames.length === 0 ? (
    • - {addableNames.length === 0 ? 'No addable elements found.' : 'No results found.'} + {addableNames.length === 0 ? 'No addable components found.' : 'No results found.'}
    • ) : ( filteredNames.map((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..de4f68b8 --- /dev/null +++ b/src/main/frontend/app/routes/configurations/use-non-canvas-components.ts @@ -0,0 +1,50 @@ +import { useCallback, useEffect, useState } from 'react' +import { + getNonCanvasComponentsFromConfiguration, + type NonCanvasComponent, +} from '~/services/non-canvas-component-service' + +const PATH_SEPARATOR = '\n' + +interface 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 = configurationPaths.join(PATH_SEPARATOR) + + useEffect(() => { + const paths = pathsKey ? pathsKey.split(PATH_SEPARATOR) : [] + 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/configurations/use-non-canvas-elements.ts b/src/main/frontend/app/routes/configurations/use-non-canvas-elements.ts deleted file mode 100644 index 79ee6a78..00000000 --- a/src/main/frontend/app/routes/configurations/use-non-canvas-elements.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { useCallback, useEffect, useState } from 'react' -import { getNonCanvasElementsFromConfiguration, type NonCanvasElement } from '~/services/non-canvas-element-service' - -const PATH_SEPARATOR = '\n' - -interface NonCanvasElementsState { - elementsByPath: Record - loadingByPath: Record - replaceElements: (configurationPath: string, elements: NonCanvasElement[]) => void -} - -export function useNonCanvasElements(projectName: string, configurationPaths: string[]): NonCanvasElementsState { - const [elementsByPath, setElementsByPath] = useState>({}) - const [loadingByPath, setLoadingByPath] = useState>({}) - const pathsKey = configurationPaths.join(PATH_SEPARATOR) - - useEffect(() => { - const paths = pathsKey ? pathsKey.split(PATH_SEPARATOR) : [] - const controller = new AbortController() - - setLoadingByPath(Object.fromEntries(paths.map((path) => [path, true]))) - - for (const path of paths) { - getNonCanvasElementsFromConfiguration(projectName, path, controller.signal) - .then((elements) => { - if (controller.signal.aborted) return - setElementsByPath((previous) => ({ ...previous, [path]: elements })) - }) - .catch(() => { - if (controller.signal.aborted) return - setElementsByPath((previous) => ({ ...previous, [path]: [] })) - }) - .finally(() => { - if (controller.signal.aborted) return - setLoadingByPath((previous) => ({ ...previous, [path]: false })) - }) - } - - return () => controller.abort() - }, [projectName, pathsKey]) - - const replaceElements = useCallback((configurationPath: string, elements: NonCanvasElement[]) => { - setElementsByPath((previous) => ({ ...previous, [configurationPath]: elements })) - }, []) - - return { elementsByPath, loadingByPath, replaceElements } -} 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..a1474ff5 --- /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 interface 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 = new Set() + 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.add(name) + return + } + + const childNames = getAllowedChildElementsForElement(xsdDocument, name).filter( + (childName) => childName !== name && elements[childName] && !isParameter(childName), + ) + + if (childNames.length === 0) { + addableNames.add(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/frontend/app/services/non-canvas-element-service.ts b/src/main/frontend/app/services/non-canvas-element-service.ts deleted file mode 100644 index 5cbc39d2..00000000 --- a/src/main/frontend/app/services/non-canvas-element-service.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { apiFetch } from '~/utils/api' -import { parseXsd, getFirstLevelElementsForType } from '~/utils/xsd-utils' -import type { Elements } from '@frankframework/doc-library-core' - -export interface NonCanvasElement { - tagName: string - name: string | null - index: number - attributes: Record -} - -const ROOT_TYPE_CANDIDATES = ['ConfigurationType', 'ModuleType', 'Configuration', 'Module'] - -function getBaseUrl(projectName: string): string { - return `/projects/${encodeURIComponent(projectName)}/non-canvas-elements` -} - -export function getAddableNonCanvasElementNames(xsdContent: string | null, elements: Elements | null): string[] { - if (!xsdContent || !elements) return [] - - const xsdDocument = parseXsd(xsdContent) - const names = new Set() - for (const rootType of ROOT_TYPE_CANDIDATES) { - for (const name of getFirstLevelElementsForType(xsdDocument, rootType)) names.add(name) - } - names.delete('Adapter') - - return [...names].filter((name) => elements[name]).toSorted((first, second) => first.localeCompare(second)) -} - -export async function getNonCanvasElementsFromConfiguration( - projectName: string, - configurationPath: string, - signal?: AbortSignal, -): Promise { - return apiFetch( - `${getBaseUrl(projectName)}?configurationPath=${encodeURIComponent(configurationPath)}`, - { signal }, - ) -} - -export async function addNonCanvasElement( - projectName: string, - configurationPath: string, - tagName: string, - attributes: Record, -): Promise { - return apiFetch(getBaseUrl(projectName), { - method: 'POST', - body: JSON.stringify({ configurationPath, tagName, attributes }), - }) -} - -export async function updateNonCanvasElement( - 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 deleteNonCanvasElement( - 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/noncanvaselement/NonCanvasElementService.java b/src/main/java/org/frankframework/flow/noncanvascomponent/NonCanvasComponentService.java similarity index 67% rename from src/main/java/org/frankframework/flow/noncanvaselement/NonCanvasElementService.java rename to src/main/java/org/frankframework/flow/noncanvascomponent/NonCanvasComponentService.java index f9f46ccf..cfb504c3 100644 --- a/src/main/java/org/frankframework/flow/noncanvaselement/NonCanvasElementService.java +++ b/src/main/java/org/frankframework/flow/noncanvascomponent/NonCanvasComponentService.java @@ -1,4 +1,4 @@ -package org.frankframework.flow.noncanvaselement; +package org.frankframework.flow.noncanvascomponent; import java.io.IOException; import java.io.StringReader; @@ -14,7 +14,7 @@ import org.frankframework.flow.exception.ApiException; import org.frankframework.flow.filesystem.FileSystemStorage; import org.frankframework.flow.utility.XmlConfigurationUtils; -import org.frankframework.flow.utility.XmlNonCanvasElementUtils; +import org.frankframework.flow.utility.XmlNonCanvasComponentUtils; import org.frankframework.flow.utility.XmlSecurityUtils; import org.springframework.http.HttpStatus; import org.springframework.stereotype.Service; @@ -25,70 +25,70 @@ @Log4j2 @Service -public class NonCanvasElementService { +public class NonCanvasComponentService { private static final String NAME_ATTRIBUTE = "name"; private final FileSystemStorage fileSystemStorage; - public NonCanvasElementService(FileSystemStorage fileSystemStorage) { + public NonCanvasComponentService(FileSystemStorage fileSystemStorage) { this.fileSystemStorage = fileSystemStorage; } - public List getNonCanvasElements(String configurationPath) { + public List getNonCanvasComponents(String configurationPath) { Document configurationDocument = readConfigurationDocument(configurationPath); return toDataTransferObjects(configurationDocument); } - public List addNonCanvasElement(String configurationPath, String tagName, Map attributes) { + public List addNonCanvasComponent(String configurationPath, String tagName, Map attributes) { validateTagName(tagName); Document configurationDocument = readConfigurationDocument(configurationPath); - XmlNonCanvasElementUtils.addNonCanvasElement(configurationDocument, tagName, attributes); + XmlNonCanvasComponentUtils.addNonCanvasComponent(configurationDocument, tagName, attributes); return writeAndList(configurationPath, configurationDocument); } - public List updateNonCanvasElement(String configurationPath, String tagName, int index, Map attributes) { + public List updateNonCanvasComponent(String configurationPath, String tagName, int index, Map attributes) { validateTagName(tagName); Document configurationDocument = readConfigurationDocument(configurationPath); - boolean updated = XmlNonCanvasElementUtils.updateNonCanvasElement(configurationDocument, tagName, index, attributes); + boolean updated = XmlNonCanvasComponentUtils.updateNonCanvasComponent(configurationDocument, tagName, index, attributes); if (!updated) { - throw new ApiException("Non-canvas element not found: " + tagName, HttpStatus.NOT_FOUND); + throw new ApiException("Non-canvas component not found: " + tagName, HttpStatus.NOT_FOUND); } return writeAndList(configurationPath, configurationDocument); } - public List deleteNonCanvasElement(String configurationPath, String tagName, int index) { + public List deleteNonCanvasComponent(String configurationPath, String tagName, int index) { validateTagName(tagName); Document configurationDocument = readConfigurationDocument(configurationPath); - boolean removed = XmlNonCanvasElementUtils.removeNonCanvasElement(configurationDocument, tagName, index); + boolean removed = XmlNonCanvasComponentUtils.removeNonCanvasComponent(configurationDocument, tagName, index); if (!removed) { - throw new ApiException("Non-canvas element not found: " + tagName, HttpStatus.NOT_FOUND); + throw new ApiException("Non-canvas component not found: " + tagName, HttpStatus.NOT_FOUND); } return writeAndList(configurationPath, configurationDocument); } - private List writeAndList(String configurationPath, Document configurationDocument) { + private List writeAndList(String configurationPath, Document configurationDocument) { writeConfigurationDocument(configurationPath, configurationDocument); return toDataTransferObjects(configurationDocument); } - private List toDataTransferObjects(Document configurationDocument) { - List elements = new ArrayList<>(); + private List toDataTransferObjects(Document configurationDocument) { + List components = new ArrayList<>(); Map occurrenceByTagName = new HashMap<>(); - for (Element element : XmlNonCanvasElementUtils.getNonCanvasElements(configurationDocument)) { + for (Element element : XmlNonCanvasComponentUtils.getNonCanvasComponents(configurationDocument)) { String tagName = element.getTagName(); int index = occurrenceByTagName.merge(tagName, 1, Integer::sum) - 1; - Map attributes = XmlNonCanvasElementUtils.getAttributes(element); + Map attributes = XmlNonCanvasComponentUtils.getAttributes(element); String name = attributes.get(NAME_ATTRIBUTE); - elements.add(new NonCanvasElementDTO(tagName, name, index, attributes)); + components.add(new NonCanvasComponentDTO(tagName, name, index, attributes)); } - return elements; + return components; } private Document readConfigurationDocument(String configurationPath) { @@ -129,7 +129,7 @@ private Path resolveExistingConfiguration(String configurationPath) { private void validateTagName(String tagName) { if (tagName == null || tagName.isBlank()) { - throw new ApiException("Element type must not be empty", HttpStatus.BAD_REQUEST); + 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/noncanvaselement/NonCanvasElementController.java b/src/main/java/org/frankframework/flow/noncanvaselement/NonCanvasElementController.java deleted file mode 100644 index 91d577b5..00000000 --- a/src/main/java/org/frankframework/flow/noncanvaselement/NonCanvasElementController.java +++ /dev/null @@ -1,51 +0,0 @@ -package org.frankframework.flow.noncanvaselement; - -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-elements") -public class NonCanvasElementController { - - private final NonCanvasElementService nonCanvasElementService; - - public NonCanvasElementController(NonCanvasElementService nonCanvasElementService) { - this.nonCanvasElementService = nonCanvasElementService; - } - - @GetMapping - public ResponseEntity> getNonCanvasElements(@RequestParam String configurationPath) { - return ResponseEntity.ok(nonCanvasElementService.getNonCanvasElements(configurationPath)); - } - - @PostMapping - public ResponseEntity> addNonCanvasElement(@RequestBody NonCanvasElementCreateDTO request) { - List elements = - nonCanvasElementService.addNonCanvasElement(request.configurationPath(), request.tagName(), request.attributes()); - return ResponseEntity.ok(elements); - } - - @PutMapping - public ResponseEntity> updateNonCanvasElement(@RequestBody NonCanvasElementUpdateDTO request) { - List elements = nonCanvasElementService.updateNonCanvasElement( - request.configurationPath(), request.tagName(), request.index(), request.attributes()); - return ResponseEntity.ok(elements); - } - - @DeleteMapping - public ResponseEntity> deleteNonCanvasElement( - @RequestParam String configurationPath, - @RequestParam String tagName, - @RequestParam int index) { - List elements = nonCanvasElementService.deleteNonCanvasElement(configurationPath, tagName, index); - return ResponseEntity.ok(elements); - } -} diff --git a/src/main/java/org/frankframework/flow/noncanvaselement/NonCanvasElementCreateDTO.java b/src/main/java/org/frankframework/flow/noncanvaselement/NonCanvasElementCreateDTO.java deleted file mode 100644 index 23af01df..00000000 --- a/src/main/java/org/frankframework/flow/noncanvaselement/NonCanvasElementCreateDTO.java +++ /dev/null @@ -1,5 +0,0 @@ -package org.frankframework.flow.noncanvaselement; - -import java.util.Map; - -public record NonCanvasElementCreateDTO(String configurationPath, String tagName, Map attributes) {} diff --git a/src/main/java/org/frankframework/flow/noncanvaselement/NonCanvasElementDTO.java b/src/main/java/org/frankframework/flow/noncanvaselement/NonCanvasElementDTO.java deleted file mode 100644 index 3ea5d272..00000000 --- a/src/main/java/org/frankframework/flow/noncanvaselement/NonCanvasElementDTO.java +++ /dev/null @@ -1,5 +0,0 @@ -package org.frankframework.flow.noncanvaselement; - -import java.util.Map; - -public record NonCanvasElementDTO(String tagName, String name, int index, Map attributes) {} diff --git a/src/main/java/org/frankframework/flow/noncanvaselement/NonCanvasElementUpdateDTO.java b/src/main/java/org/frankframework/flow/noncanvaselement/NonCanvasElementUpdateDTO.java deleted file mode 100644 index c06f3e33..00000000 --- a/src/main/java/org/frankframework/flow/noncanvaselement/NonCanvasElementUpdateDTO.java +++ /dev/null @@ -1,5 +0,0 @@ -package org.frankframework.flow.noncanvaselement; - -import java.util.Map; - -public record NonCanvasElementUpdateDTO(String configurationPath, String tagName, int index, Map attributes) {} diff --git a/src/main/java/org/frankframework/flow/utility/XmlNonCanvasElementUtils.java b/src/main/java/org/frankframework/flow/utility/XmlNonCanvasComponentUtils.java similarity index 78% rename from src/main/java/org/frankframework/flow/utility/XmlNonCanvasElementUtils.java rename to src/main/java/org/frankframework/flow/utility/XmlNonCanvasComponentUtils.java index 561ff046..15ec8477 100644 --- a/src/main/java/org/frankframework/flow/utility/XmlNonCanvasElementUtils.java +++ b/src/main/java/org/frankframework/flow/utility/XmlNonCanvasComponentUtils.java @@ -12,18 +12,18 @@ import org.w3c.dom.NodeList; @UtilityClass -public class XmlNonCanvasElementUtils { +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 getNonCanvasElements(Document configurationDocument) { - List nonCanvasElements = new ArrayList<>(); + public static List getNonCanvasComponents(Document configurationDocument) { + List nonCanvasComponents = new ArrayList<>(); Element rootElement = configurationDocument.getDocumentElement(); if (rootElement == null) { - return nonCanvasElements; + return nonCanvasComponents; } NodeList childNodes = rootElement.getChildNodes(); @@ -39,16 +39,16 @@ public static List getNonCanvasElements(Document configurationDocument) continue; } - nonCanvasElements.add(childElement); + nonCanvasComponents.add(childElement); } - return nonCanvasElements; + return nonCanvasComponents; } - public static Element findNonCanvasElement(Document configurationDocument, String tagName, int occurrenceIndex) { + public static Element findNonCanvasComponent(Document configurationDocument, String tagName, int occurrenceIndex) { int matchedOccurrences = 0; - for (Element element : getNonCanvasElements(configurationDocument)) { + for (Element element : getNonCanvasComponents(configurationDocument)) { if (!element.getTagName().equals(tagName)) { continue; } @@ -63,14 +63,14 @@ public static Element findNonCanvasElement(Document configurationDocument, Strin return null; } - public static void addNonCanvasElement(Document configurationDocument, String tagName, Map attributes) { + 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 updateNonCanvasElement(Document configurationDocument, String tagName, int occurrenceIndex, Map attributes) { - Element element = findNonCanvasElement(configurationDocument, tagName, occurrenceIndex); + public static boolean updateNonCanvasComponent(Document configurationDocument, String tagName, int occurrenceIndex, Map attributes) { + Element element = findNonCanvasComponent(configurationDocument, tagName, occurrenceIndex); if (element == null) { return false; @@ -81,8 +81,8 @@ public static boolean updateNonCanvasElement(Document configurationDocument, Str return true; } - public static boolean removeNonCanvasElement(Document configurationDocument, String tagName, int occurrenceIndex) { - Element element = findNonCanvasElement(configurationDocument, tagName, occurrenceIndex); + public static boolean removeNonCanvasComponent(Document configurationDocument, String tagName, int occurrenceIndex) { + Element element = findNonCanvasComponent(configurationDocument, tagName, occurrenceIndex); if (element == null) { return false; diff --git a/src/test/java/org/frankframework/flow/noncanvaselement/NonCanvasElementControllerTest.java b/src/test/java/org/frankframework/flow/noncanvascomponent/NonCanvasComponentControllerTest.java similarity index 58% rename from src/test/java/org/frankframework/flow/noncanvaselement/NonCanvasElementControllerTest.java rename to src/test/java/org/frankframework/flow/noncanvascomponent/NonCanvasComponentControllerTest.java index a2e16632..6dd89af1 100644 --- a/src/test/java/org/frankframework/flow/noncanvaselement/NonCanvasElementControllerTest.java +++ b/src/test/java/org/frankframework/flow/noncanvascomponent/NonCanvasComponentControllerTest.java @@ -1,4 +1,4 @@ -package org.frankframework.flow.noncanvaselement; +package org.frankframework.flow.noncanvascomponent; import static org.mockito.ArgumentMatchers.anyInt; import static org.mockito.ArgumentMatchers.anyMap; @@ -24,23 +24,23 @@ import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.springframework.test.web.servlet.MockMvc; -@WebMvcTest(NonCanvasElementController.class) +@WebMvcTest(NonCanvasComponentController.class) @AutoConfigureMockMvc(addFilters = false) -class NonCanvasElementControllerTest { +class NonCanvasComponentControllerTest { @Autowired private MockMvc mockMvc; @MockitoBean - private NonCanvasElementService nonCanvasElementService; + private NonCanvasComponentService nonCanvasComponentService; private static final String PROJECT = "FrankFlowTestProject"; - private static final String BASE_URL = "/api/projects/" + PROJECT + "/non-canvas-elements"; + private static final String BASE_URL = "/api/projects/" + PROJECT + "/non-canvas-components"; @Test - void getNonCanvasElements_returnsList() throws Exception { - NonCanvasElementDTO element = new NonCanvasElementDTO("Monitoring", "monitor", 0, Map.of("enabled", "true")); - when(nonCanvasElementService.getNonCanvasElements("config.xml")).thenReturn(List.of(element)); + 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()) @@ -49,18 +49,18 @@ void getNonCanvasElements_returnsList() throws Exception { .andExpect(jsonPath("$[0].index").value(0)) .andExpect(jsonPath("$[0].attributes.enabled").value("true")); - verify(nonCanvasElementService).getNonCanvasElements("config.xml"); + verify(nonCanvasComponentService).getNonCanvasComponents("config.xml"); } @Test - void getNonCanvasElements_missingConfigurationPath_returns400() throws Exception { + void getNonCanvasComponents_missingConfigurationPath_returns400() throws Exception { mockMvc.perform(get(BASE_URL).accept(MediaType.APPLICATION_JSON)) .andExpect(status().isBadRequest()); } @Test - void getNonCanvasElements_serviceThrowsNotFound_returns404() throws Exception { - when(nonCanvasElementService.getNonCanvasElements("missing.xml")) + 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)) @@ -70,10 +70,10 @@ void getNonCanvasElements_serviceThrowsNotFound_returns404() throws Exception { } @Test - void addNonCanvasElement_deserializesBodyAndReturnsUpdatedList() throws Exception { - NonCanvasElementDTO element = new NonCanvasElementDTO("Scheduler", "daily", 0, Map.of("name", "daily")); - when(nonCanvasElementService.addNonCanvasElement("config.xml", "Scheduler", Map.of("name", "daily"))) - .thenReturn(List.of(element)); + 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\"}}"; @@ -82,14 +82,14 @@ void addNonCanvasElement_deserializesBodyAndReturnsUpdatedList() throws Exceptio .andExpect(jsonPath("$[0].tagName").value("Scheduler")) .andExpect(jsonPath("$[0].name").value("daily")); - verify(nonCanvasElementService).addNonCanvasElement("config.xml", "Scheduler", Map.of("name", "daily")); + verify(nonCanvasComponentService).addNonCanvasComponent("config.xml", "Scheduler", Map.of("name", "daily")); } @Test - void updateNonCanvasElement_deserializesBodyAndReturnsUpdatedList() throws Exception { - NonCanvasElementDTO element = new NonCanvasElementDTO("Monitoring", "monitor", 0, Map.of("enabled", "false")); - when(nonCanvasElementService.updateNonCanvasElement("config.xml", "Monitoring", 0, Map.of("enabled", "false"))) - .thenReturn(List.of(element)); + 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\"}}"; @@ -98,25 +98,25 @@ void updateNonCanvasElement_deserializesBodyAndReturnsUpdatedList() throws Excep .andExpect(status().isOk()) .andExpect(jsonPath("$[0].attributes.enabled").value("false")); - verify(nonCanvasElementService).updateNonCanvasElement("config.xml", "Monitoring", 0, Map.of("enabled", "false")); + verify(nonCanvasComponentService).updateNonCanvasComponent("config.xml", "Monitoring", 0, Map.of("enabled", "false")); } @Test - void updateNonCanvasElement_serviceThrowsNotFound_returns404() throws Exception { - when(nonCanvasElementService.updateNonCanvasElement(anyString(), anyString(), anyInt(), anyMap())) - .thenThrow(new ApiException("Non-canvas element not found: Monitoring", HttpStatus.NOT_FOUND)); + 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 element not found: Monitoring")); + .andExpect(jsonPath("$.error").value("Non-canvas component not found: Monitoring")); } @Test - void deleteNonCanvasElement_returnsRemainingList() throws Exception { - when(nonCanvasElementService.deleteNonCanvasElement("config.xml", "Scheduler", 0)).thenReturn(List.of()); + void deleteNonCanvasComponent_returnsRemainingList() throws Exception { + when(nonCanvasComponentService.deleteNonCanvasComponent("config.xml", "Scheduler", 0)).thenReturn(List.of()); mockMvc.perform(delete(BASE_URL) .param("configurationPath", "config.xml") @@ -126,19 +126,19 @@ void deleteNonCanvasElement_returnsRemainingList() throws Exception { .andExpect(jsonPath("$").isArray()) .andExpect(jsonPath("$.length()").value(0)); - verify(nonCanvasElementService).deleteNonCanvasElement("config.xml", "Scheduler", 0); + verify(nonCanvasComponentService).deleteNonCanvasComponent("config.xml", "Scheduler", 0); } @Test - void deleteNonCanvasElement_serviceThrowsNotFound_returns404() throws Exception { - when(nonCanvasElementService.deleteNonCanvasElement("config.xml", "Scheduler", 9)) - .thenThrow(new ApiException("Non-canvas element not found: Scheduler", HttpStatus.NOT_FOUND)); + 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 element not found: Scheduler")); + .andExpect(jsonPath("$.error").value("Non-canvas component not found: Scheduler")); } } diff --git a/src/test/java/org/frankframework/flow/noncanvaselement/NonCanvasElementServiceTest.java b/src/test/java/org/frankframework/flow/noncanvascomponent/NonCanvasComponentServiceTest.java similarity index 62% rename from src/test/java/org/frankframework/flow/noncanvaselement/NonCanvasElementServiceTest.java rename to src/test/java/org/frankframework/flow/noncanvascomponent/NonCanvasComponentServiceTest.java index 5b3f7b92..6f3d96dc 100644 --- a/src/test/java/org/frankframework/flow/noncanvaselement/NonCanvasElementServiceTest.java +++ b/src/test/java/org/frankframework/flow/noncanvascomponent/NonCanvasComponentServiceTest.java @@ -1,4 +1,4 @@ -package org.frankframework.flow.noncanvaselement; +package org.frankframework.flow.noncanvascomponent; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; @@ -29,12 +29,12 @@ import org.springframework.http.HttpStatus; @ExtendWith(MockitoExtension.class) -class NonCanvasElementServiceTest { +class NonCanvasComponentServiceTest { @Mock private FileSystemStorage fileSystemStorage; - private NonCanvasElementService nonCanvasElementService; + private NonCanvasComponentService nonCanvasComponentService; @TempDir Path tempDir; @@ -55,7 +55,7 @@ class NonCanvasElementServiceTest { @BeforeEach void setUp() { - nonCanvasElementService = new NonCanvasElementService(fileSystemStorage); + nonCanvasComponentService = new NonCanvasComponentService(fileSystemStorage); } private void stubToAbsolutePath() { @@ -91,23 +91,23 @@ private Path writeConfiguration(String content) throws IOException { } @Test - void getNonCanvasElements_returnsDirectChildrenExcludingAdapters() throws Exception { + void getNonCanvasComponents_returnsDirectChildrenExcludingAdapters() throws Exception { stubToAbsolutePath(); stubReadFile(); Path file = writeConfiguration(CONFIGURATION); - List elements = nonCanvasElementService.getNonCanvasElements(file.toString()); + List components = nonCanvasComponentService.getNonCanvasComponents(file.toString()); - assertEquals(2, elements.size()); - assertEquals("Scheduler", elements.get(0).tagName()); - assertNull(elements.get(0).name()); - assertEquals("Monitoring", elements.get(1).tagName()); - assertEquals("monitor", elements.get(1).name()); - assertEquals("true", elements.get(1).attributes().get("enabled")); + 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 getNonCanvasElements_assignsOccurrenceIndexPerTagName() throws Exception { + void getNonCanvasComponents_assignsOccurrenceIndexPerTagName() throws Exception { stubToAbsolutePath(); stubReadFile(); Path file = writeConfiguration(""" @@ -118,67 +118,67 @@ void getNonCanvasElements_assignsOccurrenceIndexPerTagName() throws Exception { """); - List elements = nonCanvasElementService.getNonCanvasElements(file.toString()); + List components = nonCanvasComponentService.getNonCanvasComponents(file.toString()); - assertEquals(3, elements.size()); - assertEquals(0, elements.get(0).index()); - assertEquals("first", elements.get(0).name()); - assertEquals(0, elements.get(1).index()); - assertEquals(1, elements.get(2).index()); - assertEquals("second", elements.get(2).name()); + 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 getNonCanvasElements_blankPath_throwsBadRequest() { + void getNonCanvasComponents_blankPath_throwsBadRequest() { ApiException exception = - assertThrows(ApiException.class, () -> nonCanvasElementService.getNonCanvasElements(" ")); + assertThrows(ApiException.class, () -> nonCanvasComponentService.getNonCanvasComponents(" ")); assertEquals(HttpStatus.BAD_REQUEST, exception.getStatus()); } @Test - void getNonCanvasElements_fileNotFound_throwsNotFound() { + void getNonCanvasComponents_fileNotFound_throwsNotFound() { stubToAbsolutePath(); String path = tempDir.resolve("missing.xml").toString(); ApiException exception = - assertThrows(ApiException.class, () -> nonCanvasElementService.getNonCanvasElements(path)); + assertThrows(ApiException.class, () -> nonCanvasComponentService.getNonCanvasComponents(path)); assertEquals(HttpStatus.NOT_FOUND, exception.getStatus()); } @Test - void getNonCanvasElements_pathIsDirectory_throwsNotFound() throws IOException { + void getNonCanvasComponents_pathIsDirectory_throwsNotFound() throws IOException { stubToAbsolutePath(); Path directory = Files.createDirectory(tempDir.resolve("subdir")); ApiException exception = - assertThrows(ApiException.class, () -> nonCanvasElementService.getNonCanvasElements(directory.toString())); + assertThrows(ApiException.class, () -> nonCanvasComponentService.getNonCanvasComponents(directory.toString())); assertEquals(HttpStatus.NOT_FOUND, exception.getStatus()); } @Test - void getNonCanvasElements_malformedXml_throwsBadRequest() throws Exception { + void getNonCanvasComponents_malformedXml_throwsBadRequest() throws Exception { stubToAbsolutePath(); stubReadFile(); Path file = writeConfiguration(""); ApiException exception = - assertThrows(ApiException.class, () -> nonCanvasElementService.getNonCanvasElements(file.toString())); + assertThrows(ApiException.class, () -> nonCanvasComponentService.getNonCanvasComponents(file.toString())); assertEquals(HttpStatus.BAD_REQUEST, exception.getStatus()); } @Test - void addNonCanvasElement_appendsElementAndPersists() throws Exception { + void addNonCanvasComponent_appendsComponentAndPersists() throws Exception { stubToAbsolutePath(); stubReadFile(); stubWriteFile(); Path file = writeConfiguration(""); - List elements = nonCanvasElementService.addNonCanvasElement( + List components = nonCanvasComponentService.addNonCanvasComponent( file.toString(), "Monitoring", Map.of("name", "monitor", "enabled", "true")); - assertEquals(1, elements.size()); - assertEquals("Monitoring", elements.getFirst().tagName()); - assertEquals("monitor", elements.getFirst().name()); + 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); @@ -187,17 +187,17 @@ void addNonCanvasElement_appendsElementAndPersists() throws Exception { } @Test - void addNonCanvasElement_blankTagName_throwsBadRequestAndDoesNotWrite() throws IOException { + void addNonCanvasComponent_blankTagName_throwsBadRequestAndDoesNotWrite() throws IOException { ApiException exception = assertThrows( ApiException.class, - () -> nonCanvasElementService.addNonCanvasElement("configuration.xml", " ", Map.of())); + () -> nonCanvasComponentService.addNonCanvasComponent("configuration.xml", " ", Map.of())); assertEquals(HttpStatus.BAD_REQUEST, exception.getStatus()); verify(fileSystemStorage, never()).writeFile(anyString(), anyString()); } @Test - void updateNonCanvasElement_replacesAttributesAndPersists() throws Exception { + void updateNonCanvasComponent_replacesAttributesAndPersists() throws Exception { stubToAbsolutePath(); stubReadFile(); stubWriteFile(); @@ -207,30 +207,30 @@ void updateNonCanvasElement_replacesAttributesAndPersists() throws Exception { """); - List elements = nonCanvasElementService.updateNonCanvasElement( + List components = nonCanvasComponentService.updateNonCanvasComponent( file.toString(), "Monitoring", 0, Map.of("name", "monitor", "enabled", "false")); - assertEquals(1, elements.size()); - assertEquals("false", elements.getFirst().attributes().get("enabled")); + assertEquals(1, components.size()); + assertEquals("false", components.getFirst().attributes().get("enabled")); assertTrue(Files.readString(file, StandardCharsets.UTF_8).contains("enabled=\"false\"")); } @Test - void updateNonCanvasElement_notFound_throwsNotFoundAndDoesNotWrite() throws Exception { + void updateNonCanvasComponent_notFound_throwsNotFoundAndDoesNotWrite() throws Exception { stubToAbsolutePath(); stubReadFile(); Path file = writeConfiguration(""); ApiException exception = assertThrows( ApiException.class, - () -> nonCanvasElementService.updateNonCanvasElement(file.toString(), "Scheduler", 0, Map.of("name", "x"))); + () -> nonCanvasComponentService.updateNonCanvasComponent(file.toString(), "Scheduler", 0, Map.of("name", "x"))); assertEquals(HttpStatus.NOT_FOUND, exception.getStatus()); verify(fileSystemStorage, never()).writeFile(anyString(), anyString()); } @Test - void deleteNonCanvasElement_removesElementAndPersists() throws Exception { + void deleteNonCanvasComponent_removesComponentAndPersists() throws Exception { stubToAbsolutePath(); stubReadFile(); stubWriteFile(); @@ -241,22 +241,22 @@ void deleteNonCanvasElement_removesElementAndPersists() throws Exception { """); - List elements = nonCanvasElementService.deleteNonCanvasElement(file.toString(), "Scheduler", 0); + List components = nonCanvasComponentService.deleteNonCanvasComponent(file.toString(), "Scheduler", 0); - assertEquals(1, elements.size()); - assertEquals("Monitoring", elements.getFirst().tagName()); + assertEquals(1, components.size()); + assertEquals("Monitoring", components.getFirst().tagName()); assertFalse(Files.readString(file, StandardCharsets.UTF_8).contains(""); ApiException exception = assertThrows( ApiException.class, - () -> nonCanvasElementService.deleteNonCanvasElement(file.toString(), "Scheduler", 0)); + () -> 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/XmlNonCanvasElementUtilsTest.java b/src/test/java/org/frankframework/flow/utility/XmlNonCanvasComponentUtilsTest.java similarity index 50% rename from src/test/java/org/frankframework/flow/utility/XmlNonCanvasElementUtilsTest.java rename to src/test/java/org/frankframework/flow/utility/XmlNonCanvasComponentUtilsTest.java index d09e6e56..3cc9b16a 100644 --- a/src/test/java/org/frankframework/flow/utility/XmlNonCanvasElementUtilsTest.java +++ b/src/test/java/org/frankframework/flow/utility/XmlNonCanvasComponentUtilsTest.java @@ -14,7 +14,7 @@ import org.w3c.dom.Document; import org.w3c.dom.Element; -class XmlNonCanvasElementUtilsTest { +class XmlNonCanvasComponentUtilsTest { private static final String CONFIGURATION = """ @@ -37,19 +37,19 @@ private Document parseXml(String xml) throws Exception { } @Test - void getNonCanvasElements_excludesAdapters() throws Exception { + void getNonCanvasComponents_excludesAdapters() throws Exception { Document document = parseXml(CONFIGURATION); - List elements = XmlNonCanvasElementUtils.getNonCanvasElements(document); + List components = XmlNonCanvasComponentUtils.getNonCanvasComponents(document); - assertEquals(3, elements.size()); - assertEquals("Scheduler", elements.get(0).getTagName()); - assertEquals("Monitoring", elements.get(1).getTagName()); - assertEquals("JmsRealms", elements.get(2).getTagName()); + assertEquals(3, components.size()); + assertEquals("Scheduler", components.get(0).getTagName()); + assertEquals("Monitoring", components.get(1).getTagName()); + assertEquals("JmsRealms", components.get(2).getTagName()); } @Test - void getNonCanvasElements_excludesNamespacedFlowMetadata() throws Exception { + void getNonCanvasComponents_excludesNamespacedFlowMetadata() throws Exception { String xml = """ @@ -58,24 +58,24 @@ void getNonCanvasElements_excludesNamespacedFlowMetadata() throws Exception { """; Document document = parseXml(xml); - List elements = XmlNonCanvasElementUtils.getNonCanvasElements(document); + List components = XmlNonCanvasComponentUtils.getNonCanvasComponents(document); - assertEquals(1, elements.size()); - assertEquals("Scheduler", elements.getFirst().getTagName()); + assertEquals(1, components.size()); + assertEquals("Scheduler", components.getFirst().getTagName()); } @Test void getAttributes_returnsRegularAttributesWithoutNamespaceDeclarations() throws Exception { Document document = parseXml(CONFIGURATION); - Element monitoring = XmlNonCanvasElementUtils.findNonCanvasElement(document, "Monitoring", 0); + Element monitoring = XmlNonCanvasComponentUtils.findNonCanvasComponent(document, "Monitoring", 0); - Map attributes = XmlNonCanvasElementUtils.getAttributes(monitoring); + Map attributes = XmlNonCanvasComponentUtils.getAttributes(monitoring); assertEquals(Map.of("name", "monitor", "enabled", "true"), attributes); } @Test - void findNonCanvasElement_returnsCorrectOccurrence() throws Exception { + void findNonCanvasComponent_returnsCorrectOccurrence() throws Exception { String xml = """ @@ -84,86 +84,86 @@ void findNonCanvasElement_returnsCorrectOccurrence() throws Exception { """; Document document = parseXml(xml); - Element second = XmlNonCanvasElementUtils.findNonCanvasElement(document, "Monitoring", 1); + Element second = XmlNonCanvasComponentUtils.findNonCanvasComponent(document, "Monitoring", 1); assertNotNull(second); assertEquals("second", second.getAttribute("name")); } @Test - void findNonCanvasElement_returnsNullWhenOccurrenceMissing() throws Exception { + void findNonCanvasComponent_returnsNullWhenOccurrenceMissing() throws Exception { Document document = parseXml(CONFIGURATION); - assertNull(XmlNonCanvasElementUtils.findNonCanvasElement(document, "Monitoring", 5)); + assertNull(XmlNonCanvasComponentUtils.findNonCanvasComponent(document, "Monitoring", 5)); } @Test - void addNonCanvasElement_appendsElementWithAttributes() throws Exception { + void addNonCanvasComponent_appendsComponentWithAttributes() throws Exception { Document document = parseXml(CONFIGURATION); - XmlNonCanvasElementUtils.addNonCanvasElement(document, "SapSystems", Map.of("name", "sap")); + XmlNonCanvasComponentUtils.addNonCanvasComponent(document, "SapSystems", Map.of("name", "sap")); - Element added = XmlNonCanvasElementUtils.findNonCanvasElement(document, "SapSystems", 0); + Element added = XmlNonCanvasComponentUtils.findNonCanvasComponent(document, "SapSystems", 0); assertNotNull(added); assertEquals("sap", added.getAttribute("name")); } @Test - void addNonCanvasElement_skipsBlankAttributeValues() throws Exception { + void addNonCanvasComponent_skipsBlankAttributeValues() throws Exception { Document document = parseXml(""); - XmlNonCanvasElementUtils.addNonCanvasElement(document, "Job", Map.of("name", "nightly", "description", " ")); + XmlNonCanvasComponentUtils.addNonCanvasComponent(document, "Job", Map.of("name", "nightly", "description", " ")); - Element added = XmlNonCanvasElementUtils.findNonCanvasElement(document, "Job", 0); + Element added = XmlNonCanvasComponentUtils.findNonCanvasComponent(document, "Job", 0); assertEquals("nightly", added.getAttribute("name")); assertFalse(added.hasAttribute("description")); } @Test - void updateNonCanvasElement_replacesAttributesAndKeepsChildren() throws Exception { + void updateNonCanvasComponent_replacesAttributesAndKeepsChildren() throws Exception { Document document = parseXml(CONFIGURATION); - boolean updated = XmlNonCanvasElementUtils.updateNonCanvasElement(document, "Scheduler", 0, Map.of("name", "renamed")); + boolean updated = XmlNonCanvasComponentUtils.updateNonCanvasComponent(document, "Scheduler", 0, Map.of("name", "renamed")); assertTrue(updated); - Element scheduler = XmlNonCanvasElementUtils.findNonCanvasElement(document, "Scheduler", 0); + Element scheduler = XmlNonCanvasComponentUtils.findNonCanvasComponent(document, "Scheduler", 0); assertEquals("renamed", scheduler.getAttribute("name")); assertEquals(1, scheduler.getElementsByTagName("Job").getLength()); } @Test - void updateNonCanvasElement_removesClearedAttributes() throws Exception { + void updateNonCanvasComponent_removesClearedAttributes() throws Exception { Document document = parseXml(CONFIGURATION); - XmlNonCanvasElementUtils.updateNonCanvasElement(document, "Monitoring", 0, Map.of("name", "monitor")); + XmlNonCanvasComponentUtils.updateNonCanvasComponent(document, "Monitoring", 0, Map.of("name", "monitor")); - Element monitoring = XmlNonCanvasElementUtils.findNonCanvasElement(document, "Monitoring", 0); + Element monitoring = XmlNonCanvasComponentUtils.findNonCanvasComponent(document, "Monitoring", 0); assertEquals("monitor", monitoring.getAttribute("name")); assertFalse(monitoring.hasAttribute("enabled")); } @Test - void updateNonCanvasElement_returnsFalseWhenMissing() throws Exception { + void updateNonCanvasComponent_returnsFalseWhenMissing() throws Exception { Document document = parseXml(CONFIGURATION); - assertFalse(XmlNonCanvasElementUtils.updateNonCanvasElement(document, "Monitoring", 4, Map.of("name", "x"))); + assertFalse(XmlNonCanvasComponentUtils.updateNonCanvasComponent(document, "Monitoring", 4, Map.of("name", "x"))); } @Test - void removeNonCanvasElement_removesElement() throws Exception { + void removeNonCanvasComponent_removesComponent() throws Exception { Document document = parseXml(CONFIGURATION); - boolean removed = XmlNonCanvasElementUtils.removeNonCanvasElement(document, "Scheduler", 0); + boolean removed = XmlNonCanvasComponentUtils.removeNonCanvasComponent(document, "Scheduler", 0); assertTrue(removed); - assertNull(XmlNonCanvasElementUtils.findNonCanvasElement(document, "Scheduler", 0)); + assertNull(XmlNonCanvasComponentUtils.findNonCanvasComponent(document, "Scheduler", 0)); assertEquals(1, document.getElementsByTagName("Adapter").getLength()); } @Test - void removeNonCanvasElement_returnsFalseWhenMissing() throws Exception { + void removeNonCanvasComponent_returnsFalseWhenMissing() throws Exception { Document document = parseXml(CONFIGURATION); - assertFalse(XmlNonCanvasElementUtils.removeNonCanvasElement(document, "Monitoring", 9)); + assertFalse(XmlNonCanvasComponentUtils.removeNonCanvasComponent(document, "Monitoring", 9)); } } From fcf9a97f8b65667013c740e730f0beba59230c71 Mon Sep 17 00:00:00 2001 From: stijnpotters Date: Mon, 29 Jun 2026 15:14:00 +0200 Subject: [PATCH 5/6] Refactor interface declarations to type aliases for consistency across non-canvas components --- .../app/routes/configurations/configuration-file-tile.tsx | 1 + 1 file changed, 1 insertion(+) 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 6efebbb1..1394fd26 100644 --- a/src/main/frontend/app/routes/configurations/configuration-file-tile.tsx +++ b/src/main/frontend/app/routes/configurations/configuration-file-tile.tsx @@ -43,6 +43,7 @@ interface AdapterListItemProperties { onConfigure: () => void onOpenInStudio: (adapterName: string, adapterPosition: number) => void } + interface ComponentListItemProperties { component: NonCanvasComponent onConfigure: () => void From fe2d1bd2e092f69bea93e0069b0e56532ac149cb Mon Sep 17 00:00:00 2001 From: stijnpotters Date: Wed, 1 Jul 2026 11:28:31 +0200 Subject: [PATCH 6/6] Refactor component properties to use type aliases and replace button implementations with ContextEditorFooter for improved consistency and clarity --- .../app/components/context-editor-footer.tsx | 47 ++++++++ .../frontend/app/components/inputs/button.tsx | 4 +- .../routes/configurations/adapter-context.tsx | 31 ++---- .../configurations/adapter-list-item.tsx | 32 ++++++ .../add-non-canvas-component-menu.tsx | 2 +- .../configurations/component-list-item.tsx | 20 ++++ .../routes/configurations/component-row.tsx | 36 +++++++ .../configuration-file-tile.tsx | 102 +----------------- .../configurations/configuration-overview.tsx | 7 +- .../configurations/configuration-utils.ts | 7 ++ .../non-canvas-component-context.tsx | 30 ++---- .../use-non-canvas-components.ts | 8 +- .../routes/studio/context/node-context.tsx | 33 ++---- .../services/non-canvas-component-service.ts | 10 +- .../NonCanvasComponentService.java | 4 +- .../NonCanvasComponentServiceTest.java | 4 +- 16 files changed, 199 insertions(+), 178 deletions(-) create mode 100644 src/main/frontend/app/components/context-editor-footer.tsx create mode 100644 src/main/frontend/app/routes/configurations/adapter-list-item.tsx create mode 100644 src/main/frontend/app/routes/configurations/component-list-item.tsx create mode 100644 src/main/frontend/app/routes/configurations/component-row.tsx create mode 100644 src/main/frontend/app/routes/configurations/configuration-utils.ts 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/routes/configurations/adapter-context.tsx b/src/main/frontend/app/routes/configurations/adapter-context.tsx index 6969e859..e0eab430 100644 --- a/src/main/frontend/app/routes/configurations/adapter-context.tsx +++ b/src/main/frontend/app/routes/configurations/adapter-context.tsx @@ -1,15 +1,15 @@ import { useEffect, useState } from 'react' -import Button from '~/components/inputs/button' import Input from '~/components/inputs/input' +import ContextEditorFooter from '~/components/context-editor-footer' import { deleteAdapter, renameAdapter } from '~/services/adapter-service' -export interface AdapterEditorState { +export type AdapterEditorState = { configPath: string adapterName: string adapterPosition: number } -interface AdapterContextProperties { +type AdapterContextProperties = { projectName: string editor: AdapterEditorState onSaved: () => void @@ -80,23 +80,14 @@ export default function AdapterContext({
    -
    -
    - - - -
    - - {errorMessage &&

    {errorMessage}

    } -
    +
    ) } 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 index cddb87f1..66be14ae 100644 --- 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 @@ -6,7 +6,7 @@ import Search from '~/components/search/search' import { useFrankConfigXsd } from '~/providers/frankconfig-xsd-provider' import { getAddableNonCanvasComponentNames } from '~/services/non-canvas-component-service' -interface AddNonCanvasComponentMenuProperties { +type AddNonCanvasComponentMenuProperties = { isOpen: boolean onClose: () => void onSelect: (tagName: string) => void 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 1394fd26..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,20 +1,18 @@ -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 LoadingSpinner from '~/components/loading-spinner' -import { useRef, useState, type DragEvent, type ReactNode } from 'react' -import { type NonCanvasComponent } from '~/services/non-canvas-component-service' -import { NON_CANVAS_DRAG_TYPE, type NonCanvasElement } from '~/services/non-canvas-element-service' -import {useRef, useState} from 'react' import { getBaseName } from '~/utils/path-utils' -import { useRef, useState, type DragEvent } from 'react' -import { type NonCanvasElement } from '~/services/non-canvas-element-service' +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 @@ -30,37 +28,6 @@ type ConfigurationFileTileProperties = { draggedTagName?: string | null } -interface ComponentRowProperties { - typeLabel: string | null - primaryLabel: string - onConfigure: () => void - action?: ReactNode -} - -interface AdapterListItemProperties { - adapterName: string - adapterPosition: number - onConfigure: () => void - onOpenInStudio: (adapterName: string, adapterPosition: number) => void -} - -interface ComponentListItemProperties { - component: NonCanvasComponent - onConfigure: () => void -} - -function isRootConfiguration(relativePath: string): boolean { - return relativePath.split(/[/\\]/).pop()?.toLowerCase() === 'configuration.xml' -} - -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 ConfigurationFileTile({ filepath, relativePath, @@ -227,62 +194,3 @@ export default function ConfigurationFileTile({
    ) } - -type AdapterListItemProperties = { - adapterName: string - adapterPosition: number - onOpenInStudio: (adapterName: string, adapterPosition: number) => void -} - -function ComponentRow({ typeLabel, primaryLabel, onConfigure, action }: Readonly) { - return ( -
  • - - {action &&
    {action}
    } -
  • - ) -} - -function AdapterListItem({ - adapterName, - adapterPosition, - onConfigure, - onOpenInStudio, -}: Readonly) { - return ( - } - label="Open in Studio" - onClick={() => onOpenInStudio(adapterName, adapterPosition)} - /> - } - /> - ) -} - -type NonCanvasComponentListItemProperties = { - element: NonCanvasElement - onConfigure: () => void -} - -function ComponentListItem({ component, onConfigure }: Readonly) { - const { typeLabel, primaryLabel } = getComponentLabels(component) - return -} diff --git a/src/main/frontend/app/routes/configurations/configuration-overview.tsx b/src/main/frontend/app/routes/configurations/configuration-overview.tsx index e54f3448..b9dc8672 100644 --- a/src/main/frontend/app/routes/configurations/configuration-overview.tsx +++ b/src/main/frontend/app/routes/configurations/configuration-overview.tsx @@ -21,9 +21,10 @@ 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' -import { relativeTo } from '~/utils/path-utils' type ConfigurationFile = { path: string @@ -197,8 +198,8 @@ export default function ConfigurationOverview() { })) return files.toSorted((a, b) => { - const aIsRoot = a.relativePath.split(/[/\\]/).pop()?.toLowerCase() === 'configuration.xml' - const bIsRoot = b.relativePath.split(/[/\\]/).pop()?.toLowerCase() === 'configuration.xml' + const aIsRoot = isRootConfiguration(a.relativePath) + const bIsRoot = isRootConfiguration(b.relativePath) if (aIsRoot === bIsRoot) return 0 return aIsRoot ? -1 : 1 }) 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 index b28dfefb..5555d701 100644 --- a/src/main/frontend/app/routes/configurations/non-canvas-component-context.tsx +++ b/src/main/frontend/app/routes/configurations/non-canvas-component-context.tsx @@ -7,6 +7,7 @@ import { } 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, @@ -15,7 +16,7 @@ import { type NonCanvasComponent, } from '~/services/non-canvas-component-service' -export interface NonCanvasComponentEditorState { +export type NonCanvasComponentEditorState = { mode: 'add' | 'edit' configPath: string tagName: string @@ -23,7 +24,7 @@ export interface NonCanvasComponentEditorState { initialAttributes?: Record } -interface NonCanvasComponentContextProperties { +type NonCanvasComponentContextProperties = { projectName: string editor: NonCanvasComponentEditorState onSaved: (components: NonCanvasComponent[]) => void @@ -223,23 +224,14 @@ export default function NonCanvasComponentContext({
    -
    -
    - - - -
    - - {errorMessage &&

    {errorMessage}

    } -
    +
    ) } 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 index de4f68b8..a4a7fc56 100644 --- a/src/main/frontend/app/routes/configurations/use-non-canvas-components.ts +++ b/src/main/frontend/app/routes/configurations/use-non-canvas-components.ts @@ -4,9 +4,7 @@ import { type NonCanvasComponent, } from '~/services/non-canvas-component-service' -const PATH_SEPARATOR = '\n' - -interface NonCanvasComponentsState { +type NonCanvasComponentsState = { componentsByPath: Record loadingByPath: Record replaceComponents: (configurationPath: string, components: NonCanvasComponent[]) => void @@ -15,10 +13,10 @@ interface NonCanvasComponentsState { export function useNonCanvasComponents(projectName: string, configurationPaths: string[]): NonCanvasComponentsState { const [componentsByPath, setComponentsByPath] = useState>({}) const [loadingByPath, setLoadingByPath] = useState>({}) - const pathsKey = configurationPaths.join(PATH_SEPARATOR) + const pathsKey = JSON.stringify(configurationPaths) useEffect(() => { - const paths = pathsKey ? pathsKey.split(PATH_SEPARATOR) : [] + const paths: string[] = JSON.parse(pathsKey) const controller = new AbortController() setLoadingByPath(Object.fromEntries(paths.map((path) => [path, true]))) 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 index a1474ff5..aa5bab51 100644 --- a/src/main/frontend/app/services/non-canvas-component-service.ts +++ b/src/main/frontend/app/services/non-canvas-component-service.ts @@ -2,7 +2,7 @@ import { apiFetch } from '~/utils/api' import { parseXsd, getFirstLevelElementsForType, getAllowedChildElementsForElement } from '~/utils/xsd-utils' import type { Elements } from '@frankframework/doc-library-core' -export interface NonCanvasComponent { +export type NonCanvasComponent = { tagName: string name: string | null index: number @@ -26,7 +26,7 @@ export function getAddableNonCanvasComponentNames(xsdContent: string | null, ele const hasNameAttribute = (name: string) => Boolean(elements[name]?.attributes?.['name']) const isParameter = (name: string) => elements[name]?.labels?.['Components'] === PARAMETER_COMPONENT_LABEL - const addableNames = new Set() + const addableNames: string[] = [] const visitedNames = new Set() const collect = (name: string) => { @@ -36,7 +36,7 @@ export function getAddableNonCanvasComponentNames(xsdContent: string | null, ele if (!elements[name]) return if (hasNameAttribute(name) || NON_EXPANDABLE_NAMES.has(name)) { - addableNames.add(name) + addableNames.push(name) return } @@ -45,7 +45,7 @@ export function getAddableNonCanvasComponentNames(xsdContent: string | null, ele ) if (childNames.length === 0) { - addableNames.add(name) + addableNames.push(name) return } @@ -56,7 +56,7 @@ export function getAddableNonCanvasComponentNames(xsdContent: string | null, ele for (const name of getFirstLevelElementsForType(xsdDocument, rootType)) collect(name) } - return [...addableNames].toSorted((first, second) => first.localeCompare(second)) + return addableNames.toSorted((first, second) => first.localeCompare(second)) } export async function getNonCanvasComponentsFromConfiguration( diff --git a/src/main/java/org/frankframework/flow/noncanvascomponent/NonCanvasComponentService.java b/src/main/java/org/frankframework/flow/noncanvascomponent/NonCanvasComponentService.java index cfb504c3..af4bd151 100644 --- a/src/main/java/org/frankframework/flow/noncanvascomponent/NonCanvasComponentService.java +++ b/src/main/java/org/frankframework/flow/noncanvascomponent/NonCanvasComponentService.java @@ -99,7 +99,7 @@ private Document readConfigurationDocument(String configurationPath) { 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.BAD_REQUEST); + throw new ApiException("Failed to read configuration: " + exception.getMessage(), HttpStatus.INTERNAL_SERVER_ERROR); } } @@ -110,7 +110,7 @@ private void writeConfigurationDocument(String configurationPath, Document confi String updatedContent = XmlConfigurationUtils.convertNodeToString(configurationDocument); fileSystemStorage.writeFile(absolutePath.toString(), updatedContent); } catch (TransformerException | IOException exception) { - throw new ApiException("Failed to write configuration: " + exception.getMessage(), HttpStatus.BAD_REQUEST); + throw new ApiException("Failed to write configuration: " + exception.getMessage(), HttpStatus.INTERNAL_SERVER_ERROR); } } diff --git a/src/test/java/org/frankframework/flow/noncanvascomponent/NonCanvasComponentServiceTest.java b/src/test/java/org/frankframework/flow/noncanvascomponent/NonCanvasComponentServiceTest.java index 6f3d96dc..88ea5746 100644 --- a/src/test/java/org/frankframework/flow/noncanvascomponent/NonCanvasComponentServiceTest.java +++ b/src/test/java/org/frankframework/flow/noncanvascomponent/NonCanvasComponentServiceTest.java @@ -156,14 +156,14 @@ void getNonCanvasComponents_pathIsDirectory_throwsNotFound() throws IOException } @Test - void getNonCanvasComponents_malformedXml_throwsBadRequest() throws Exception { + void getNonCanvasComponents_malformedXml_throwsInternalServerError() throws Exception { stubToAbsolutePath(); stubReadFile(); Path file = writeConfiguration(""); ApiException exception = assertThrows(ApiException.class, () -> nonCanvasComponentService.getNonCanvasComponents(file.toString())); - assertEquals(HttpStatus.BAD_REQUEST, exception.getStatus()); + assertEquals(HttpStatus.INTERNAL_SERVER_ERROR, exception.getStatus()); } @Test