diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index d2b98739..80fdc07a 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -2,6 +2,9 @@ packages: - src/main/frontend - src/main/frontend/cypress +allowBuilds: + cypress: true + overrides: '@babel/core': '^7.29.6' brace-expansion@>=5.0.0 <5.0.6: '>=5.0.6' 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/dialog.tsx b/src/main/frontend/app/components/dialog.tsx new file mode 100644 index 00000000..47e87e0c --- /dev/null +++ b/src/main/frontend/app/components/dialog.tsx @@ -0,0 +1,31 @@ +import { type MouseEvent, type ReactNode } from 'react' +import { createPortal } from 'react-dom' +import CloseButton from '~/components/inputs/close-button' + +type DialogProperties = { + isOpen: boolean + onClose: () => void + title: string + children: ReactNode + className?: string + overlay?: ReactNode +} +export default function Dialog({ isOpen, onClose, title, children, className, overlay }: Readonly) { + if (!isOpen) return null + + const handleBackdropClick = (event: MouseEvent) => { + if (event.target === event.currentTarget) onClose() + } + + return createPortal( +
+
+

{title}

+ {children} + +
+ {overlay} +
, + document.body, + ) +} diff --git a/src/main/frontend/app/components/flow/create-node-modal.tsx b/src/main/frontend/app/components/flow/create-node-modal.tsx index a6406a61..1ae00abf 100644 --- a/src/main/frontend/app/components/flow/create-node-modal.tsx +++ b/src/main/frontend/app/components/flow/create-node-modal.tsx @@ -160,7 +160,7 @@ function CreateNodeModal({ onClose, addNodeAtPosition, positions, sourceInfo }: - diff --git a/src/main/frontend/app/components/inputs/active-icon-button.tsx b/src/main/frontend/app/components/inputs/active-icon-button.tsx new file mode 100644 index 00000000..45aaa7c8 --- /dev/null +++ b/src/main/frontend/app/components/inputs/active-icon-button.tsx @@ -0,0 +1,38 @@ +import React from 'react' +import clsx from 'clsx' + +type IconComponent = React.FC> + +type TileViewButtonProps = { + isActive: boolean + label: string + Icon: IconComponent + onClick: () => void +} + +export function ActiveIconButton({ isActive, label, Icon, onClick }: Readonly) { + return ( +
  • + +
  • + ) +} diff --git a/src/main/frontend/app/components/inputs/button.tsx b/src/main/frontend/app/components/inputs/button.tsx index 07b46be9..cf94784f 100644 --- a/src/main/frontend/app/components/inputs/button.tsx +++ b/src/main/frontend/app/components/inputs/button.tsx @@ -1,11 +1,11 @@ import React from 'react' import clsx from 'clsx' -type ButtonVariant = 'default' | 'ghost' | 'primary' | 'destructive' +type ButtonVariant = 'default' | 'ghost' | 'primary' | 'destructive' | 'unstyled' export function buttonClasses(variant: ButtonVariant = 'default', disabled?: boolean, className?: string) { return clsx( - 'rounded-md px-4 py-2', + variant !== 'unstyled' && 'rounded-md px-4 py-2', variant === 'default' && 'text-foreground border-border bg-backdrop border', variant === 'ghost' && 'text-foreground border border-transparent bg-transparent', variant === 'primary' && 'bg-brand font-medium text-white', diff --git a/src/main/frontend/app/components/inputs/icon-button.tsx b/src/main/frontend/app/components/inputs/icon-button.tsx index 20cd8763..32cba51b 100644 --- a/src/main/frontend/app/components/inputs/icon-button.tsx +++ b/src/main/frontend/app/components/inputs/icon-button.tsx @@ -23,7 +23,7 @@ export default function IconButton({ role="button" tabIndex={disabled ? -1 : 0} className={clsx( - 'icon-button group flex-shrink-0 cursor-pointer rounded p-0.5', + 'icon-button group shrink-0 cursor-pointer rounded p-0.5', disabled ? 'cursor-not-allowed opacity-40' : 'hover:bg-hover', className, )} diff --git a/src/main/frontend/app/components/sidebars-layout/sidebar-close.tsx b/src/main/frontend/app/components/sidebars-layout/sidebar-close.tsx index 20adede2..04132090 100644 --- a/src/main/frontend/app/components/sidebars-layout/sidebar-close.tsx +++ b/src/main/frontend/app/components/sidebars-layout/sidebar-close.tsx @@ -1,5 +1,5 @@ import SidebarIcon from '/icons/solar/Sidebar Minimalistic.svg?react' -import { SidebarSide, useSidebarStore } from '~/stores/sidebar-layout-store' +import { SidebarSide, useSidebarStore } from '~/components/sidebars-layout/sidebar-layout-store' import clsx from 'clsx' import { useContext } from 'react' import { SidebarContext } from '~/components/sidebars-layout/sidebar-layout' diff --git a/src/main/frontend/app/components/sidebars-layout/sidebar-content-close.tsx b/src/main/frontend/app/components/sidebars-layout/sidebar-content-close.tsx index c6af2172..935cb415 100644 --- a/src/main/frontend/app/components/sidebars-layout/sidebar-content-close.tsx +++ b/src/main/frontend/app/components/sidebars-layout/sidebar-content-close.tsx @@ -1,5 +1,5 @@ import SidebarClose, { type SidebarsCloseProperties } from '~/components/sidebars-layout/sidebar-close' -import { useSidebarStore } from '~/stores/sidebar-layout-store' +import { useSidebarStore } from '~/components/sidebars-layout/sidebar-layout-store' import { useContext } from 'react' import { SidebarContext } from '~/components/sidebars-layout/sidebar-layout' diff --git a/src/main/frontend/app/components/sidebars-layout/sidebar-header.tsx b/src/main/frontend/app/components/sidebars-layout/sidebar-header.tsx index 1c861872..e2550c6d 100644 --- a/src/main/frontend/app/components/sidebars-layout/sidebar-header.tsx +++ b/src/main/frontend/app/components/sidebars-layout/sidebar-header.tsx @@ -1,5 +1,5 @@ import SidebarClose, { type SidebarsCloseProperties } from '~/components/sidebars-layout/sidebar-close' -import { SidebarSide } from '~/stores/sidebar-layout-store' +import { SidebarSide } from '~/components/sidebars-layout/sidebar-layout-store' import clsx from 'clsx' type SidebarsHeaderProperties = { diff --git a/src/main/frontend/app/stores/sidebar-layout-store.test.ts b/src/main/frontend/app/components/sidebars-layout/sidebar-layout-store.test.ts similarity index 100% rename from src/main/frontend/app/stores/sidebar-layout-store.test.ts rename to src/main/frontend/app/components/sidebars-layout/sidebar-layout-store.test.ts diff --git a/src/main/frontend/app/stores/sidebar-layout-store.ts b/src/main/frontend/app/components/sidebars-layout/sidebar-layout-store.ts similarity index 100% rename from src/main/frontend/app/stores/sidebar-layout-store.ts rename to src/main/frontend/app/components/sidebars-layout/sidebar-layout-store.ts 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..8bd2d146 100644 --- a/src/main/frontend/app/components/sidebars-layout/sidebar-layout.tsx +++ b/src/main/frontend/app/components/sidebars-layout/sidebar-layout.tsx @@ -1,6 +1,6 @@ import React, { createContext, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' import { Allotment, type AllotmentHandle } from 'allotment' -import { SidebarSide, useSidebarStore, type VisibilityState } from '~/stores/sidebar-layout-store' +import { SidebarSide, useSidebarStore, type VisibilityState } from '~/components/sidebars-layout/sidebar-layout-store' export const SidebarContext = createContext(undefined) @@ -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,10 +41,10 @@ export default function SidebarLayout({ if (!allotmentReady || !allotmentRef.current) return if (sizes.length === 0) return - const target = sizes.map((size, i) => (visible[i] ? size : 0)) - + const target = sizes.map((size, index) => (visible[index] ? size : 0)) + if (hideLeft) target.shift() allotmentRef.current.resize(target) - }, [sizes, visible, allotmentReady]) + }, [sizes, visible, allotmentReady, hideLeft]) const handleVisibilityChange = (index: SidebarSide, value: boolean) => { setVisible(name, index, value) @@ -50,7 +52,7 @@ export default function SidebarLayout({ const saveSizes = (newSizes: number[]) => { const previous = useSidebarStore.getState().getSizes(name) ?? [] - const merged = newSizes.map((size, i) => (size === 0 ? (previous[i] ?? 0) : size)) + const merged = newSizes.map((size, index) => (size === 0 ? (previous[index] ?? 0) : size)) setSizes(name, merged) if (windowResizeOnChange) { globalThis.dispatchEvent(new Event('resize')) @@ -70,17 +72,20 @@ export default function SidebarLayout({ onChange={onChangeHandler} onDragEnd={saveSizes} onVisibleChange={handleVisibilityChange} + proportionalLayout={false} > - - {childrenArray[SidebarSide.LEFT]} - + {hideLeft || ( + + {childrenArray[SidebarSide.LEFT]} + + )} {childrenArray[SidebarSide.MIDDLE]} diff --git a/src/main/frontend/app/main.tsx b/src/main/frontend/app/main.tsx index 535bd2b2..2421ba51 100644 --- a/src/main/frontend/app/main.tsx +++ b/src/main/frontend/app/main.tsx @@ -5,8 +5,8 @@ import { Toast } from '~/components/toast' import { useTheme } from '~/hooks/use-theme' import { useProjectStore } from '~/stores/project-store' import { router } from './router' -import './app.css' import 'allotment/dist/style.css' +import './app.css' // Always last for overwriting variables function TitleSync() { const project = useProjectStore((state) => state.project) diff --git a/src/main/frontend/app/routes/configurations/adapter-context.tsx b/src/main/frontend/app/routes/configurations/adapter-context.tsx new file mode 100644 index 00000000..e0eab430 --- /dev/null +++ b/src/main/frontend/app/routes/configurations/adapter-context.tsx @@ -0,0 +1,93 @@ +import { useEffect, useState } from 'react' +import Input from '~/components/inputs/input' +import ContextEditorFooter from '~/components/context-editor-footer' +import { deleteAdapter, renameAdapter } from '~/services/adapter-service' + +export type AdapterEditorState = { + configPath: string + adapterName: string + adapterPosition: number +} + +type AdapterContextProperties = { + projectName: string + editor: AdapterEditorState + onSaved: () => void + onDeleted: () => void + onNameChange?: (name: string) => void +} + +export default function AdapterContext({ + projectName, + editor, + onSaved, + onDeleted, + onNameChange, +}: Readonly) { + const [name, setName] = useState(editor.adapterName) + const [isSaving, setIsSaving] = useState(false) + const [errorMessage, setErrorMessage] = useState('') + + useEffect(() => { + onNameChange?.(name) + }, [name, onNameChange]) + + const trimmedName = name.trim() + const canSave = trimmedName !== '' && trimmedName !== editor.adapterName && !isSaving + + const handleSave = async () => { + if (!canSave) return + setIsSaving(true) + setErrorMessage('') + try { + await renameAdapter(projectName, editor.adapterName, trimmedName, editor.configPath) + onSaved() + } catch (error) { + setErrorMessage(error instanceof Error ? error.message : `Failed to rename ${editor.adapterName}`) + setIsSaving(false) + } + } + + const handleDelete = async () => { + setIsSaving(true) + setErrorMessage('') + try { + await deleteAdapter(projectName, editor.adapterName, editor.configPath) + onDeleted() + } catch (error) { + setErrorMessage(error instanceof Error ? error.message : `Failed to delete ${editor.adapterName}`) + setIsSaving(false) + } + } + + return ( +
    +
    +
    {name}
    + +
    +
    + + setName(event.target.value)} + /> +
    +
    +
    + + +
    + ) +} diff --git a/src/main/frontend/app/routes/configurations/adapter-list-item.tsx b/src/main/frontend/app/routes/configurations/adapter-list-item.tsx new file mode 100644 index 00000000..d74f8a47 --- /dev/null +++ b/src/main/frontend/app/routes/configurations/adapter-list-item.tsx @@ -0,0 +1,32 @@ +import RulerCrossPenIcon from '/icons/solar/Ruler Cross Pen.svg?react' +import IconLabelButton from '~/components/inputs/icon-label-button' +import ComponentRow from './component-row' + +type AdapterListItemProperties = { + adapterName: string + adapterPosition: number + onConfigure: () => void + onOpenInStudio: (adapterName: string, adapterPosition: number) => void +} + +export default function AdapterListItem({ + adapterName, + adapterPosition, + onConfigure, + onOpenInStudio, +}: Readonly) { + return ( + } + label="Open in Studio" + onClick={() => onOpenInStudio(adapterName, adapterPosition)} + /> + } + /> + ) +} diff --git a/src/main/frontend/app/routes/configurations/add-configuration-modal.tsx b/src/main/frontend/app/routes/configurations/add-configuration-file-modal.tsx similarity index 64% rename from src/main/frontend/app/routes/configurations/add-configuration-modal.tsx rename to src/main/frontend/app/routes/configurations/add-configuration-file-modal.tsx index c7724b94..facfb091 100644 --- a/src/main/frontend/app/routes/configurations/add-configuration-modal.tsx +++ b/src/main/frontend/app/routes/configurations/add-configuration-file-modal.tsx @@ -7,7 +7,9 @@ import CloseButton from '~/components/inputs/close-button' import Input from '~/components/inputs/input' import DirectoryPicker from '~/components/directory-picker/directory-picker' import { fetchProject } from '~/services/project-service' -import { hasUnsafeNameChars, joinPath, relativeTo } from '~/utils/path-utils' +import { joinPath, relativeTo, SAFE_NAME_PATTERN } from '~/utils/path-utils' +import InputWithLabel from '~/components/inputs/input-with-label' +import ValidatedInput from '~/components/inputs/validatedInput' type AddConfigurationModalProperties = { isOpen: boolean @@ -17,7 +19,7 @@ type AddConfigurationModalProperties = { configurationsDirPath?: string } -export default function AddConfigurationModal({ +export default function AddConfigurationFileModal({ isOpen, onClose, onSuccess, @@ -29,6 +31,7 @@ export default function AddConfigurationModal({ const [filename, setFilename] = useState('') const [isOpenPickerOpen, setIsOpenPickerOpen] = useState(false) const [rootLocationName, setRootLocationName] = useState('') + const [isFilenameValid, setIsFilenameValid] = useState(false) const setProject = useProjectStore((s) => s.setProject) useEffect(() => { @@ -43,17 +46,6 @@ export default function AddConfigurationModal({ try { let configname = filename.trim() - if (!configname) { - setError('Configuration name cannot be empty') - setLoading(false) - return - } - - if (hasUnsafeNameChars(configname) || configname.includes('..')) { - setError('Filename may only contain letters, digits, spaces, and . _ - (no "..")') - setLoading(false) - return - } if (!configname.toLowerCase().endsWith('.xml')) { configname = `${configname}.xml` @@ -94,8 +86,6 @@ export default function AddConfigurationModal({ } const trimmedFilename = filename.trim() - const filenameHasInvalidChars = hasUnsafeNameChars(trimmedFilename) || trimmedFilename.includes('..') - const isFilenameValid = trimmedFilename.length > 0 && !filenameHasInvalidChars const displayFilename = (() => { if (!trimmedFilename) return '' @@ -111,45 +101,34 @@ export default function AddConfigurationModal({

    Add File

    Add a new configuration file.

    -
    - -
    - -
    - -
    - -
    - -
    -
    - setFilename(event.target.value)} - placeholder="Choose a filename" - aria-label="configuration filename" - /> - .xml -
    - {filenameHasInvalidChars && ( -

    - Filename may only contain letters, digits, spaces, and . _ - (no "..") -

    - )} -
    + onClick={() => setIsOpenPickerOpen(true)} + value={rootLocationName || configurationsDirPath} + /> + + + + + setFilename(event.target.value)} + placeholder="Choose a filename" + aria-label="configuration filename" + patterns={{ + 'Filename cannot be empty': /^.+$/, + 'Filename may only contain letters, digits, spaces, and . _ - (no "..")': SAFE_NAME_PATTERN, + }} + onValidChange={(isValid) => { + setIsFilenameValid(isValid) + }} + /> + .xml +
    diff --git a/src/main/frontend/app/routes/configurations/add-configuration-tile.tsx b/src/main/frontend/app/routes/configurations/add-configuration-file-tile.tsx similarity index 69% rename from src/main/frontend/app/routes/configurations/add-configuration-tile.tsx rename to src/main/frontend/app/routes/configurations/add-configuration-file-tile.tsx index 80c91e34..873c9dd6 100644 --- a/src/main/frontend/app/routes/configurations/add-configuration-tile.tsx +++ b/src/main/frontend/app/routes/configurations/add-configuration-file-tile.tsx @@ -2,10 +2,10 @@ type AddConfigurationTileProertiess = { onClick?: () => void } -export default function AddConfigurationTile({ onClick }: Readonly) { +export default function AddConfigurationFileTile({ onClick }: Readonly) { return (
    diff --git a/src/main/frontend/app/routes/configurations/add-non-canvas-component-menu.tsx b/src/main/frontend/app/routes/configurations/add-non-canvas-component-menu.tsx new file mode 100644 index 00000000..2998a81e --- /dev/null +++ b/src/main/frontend/app/routes/configurations/add-non-canvas-component-menu.tsx @@ -0,0 +1,91 @@ +import { useMemo, useState, type ChangeEvent } from 'react' +import { useFFDoc } from '@frankframework/doc-library-react' +import Button from '~/components/inputs/button' +import Dialog from '~/components/dialog' +import Search from '~/components/search/search' +import { useFrankConfigXsd } from '~/providers/frankconfig-xsd-provider' +import { getAddableNonCanvasComponentNames } from '~/services/non-canvas-component-service' + +type AddNonCanvasComponentMenuProperties = { + isOpen: boolean + onClose: () => void + onSelect: (tagName: string) => void +} + +export default function AddNonCanvasComponentMenu({ + isOpen, + onClose, + onSelect, +}: Readonly) { + const { elements } = useFFDoc() + const { xsdContent } = useFrankConfigXsd() + const [search, setSearch] = useState('') + const [selected, setSelected] = useState(null) + + const addableNames = useMemo(() => getAddableNonCanvasComponentNames(xsdContent, elements), [xsdContent, elements]) + + const filteredNames = useMemo( + () => addableNames.filter((name) => name.toLowerCase().includes(search.toLowerCase())), + [addableNames, search], + ) + + const clearAndClose = () => { + setSearch('') + setSelected(null) + onClose() + } + + 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 ( + + +
    +
      + {filteredNames.length > 0 ? ( + filteredNames.map((name) => { + const isSelected = selected === name + return ( +
    • setSelected(name)} + onDoubleClick={() => handleConfirm(name)} + className={`cursor-pointer px-3 py-2 ${ + isSelected ? 'bg-foreground-active text-background' : 'hover:bg-hover' + }`} + > + {name} +
    • + ) + }) + ) : ( +
    • + {addableNames.length === 0 ? 'No addable components found.' : 'No results found.'} +
    • + )} +
    +
    + + +
    + ) +} 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..1b8f284f --- /dev/null +++ b/src/main/frontend/app/routes/configurations/component-row.tsx @@ -0,0 +1,34 @@ +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 ( + + + {typeLabel && ( + {typeLabel} + )} + + {primaryLabel} + + + {action &&
    {action}
    } +
    + ) +} diff --git a/src/main/frontend/app/routes/configurations/configuration-file-tile.tsx b/src/main/frontend/app/routes/configurations/configuration-file-tile.tsx index f819aa84..b10852fb 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 { useNavigate } from 'react-router' +import { useRef, useState, type DragEvent } from 'react' import { openInStudio, openInEditor } from '~/actions/navigationActions' import IconButton from '~/components/inputs/icon-button' import IconLabelButton from '~/components/inputs/icon-label-button' import ConfirmDeleteDialog from '~/components/file-structure/confirm-delete-dialog' -import { useState } from 'react' +import LoadingSpinner from '~/components/loading-spinner' import { getBaseName } from '~/utils/path-utils' +import { type NonCanvasComponent } from '~/services/non-canvas-component-service' +import { isRootConfiguration } from './configuration-utils' +import AdapterListItem from './adapter-list-item' +import ComponentListItem from './component-list-item' +import { frankdocChipStyle } from '~/utils/flow-utils' +import clsx from "clsx"; type ConfigurationFileTileProperties = { filepath: string relativePath: string adapterNames: string[] + nonCanvasComponents: NonCanvasComponent[] + loadingComponents: boolean onDelete: () => Promise + onAddComponent: (configurationPath: string) => void + onEditComponent: (configurationPath: string, component: NonCanvasComponent) => void + onConfigureAdapter: (configurationPath: string, adapterName: string, adapterPosition: number) => void + onDropComponent: (configurationPath: string, tagName: string) => void + draggedTagName?: string | null + listViewTile?: boolean } export default function ConfigurationFileTile({ filepath, relativePath, adapterNames, + nonCanvasComponents, + loadingComponents, onDelete, + onAddComponent, + onEditComponent, + onConfigureAdapter, + onDropComponent, + draggedTagName = null, + listViewTile = false, }: Readonly) { const [showDeleteDialog, setShowDeleteDialog] = useState(false) + const [isDropTarget, setIsDropTarget] = useState(false) + const dragDepth = useRef(0) const navigate = useNavigate() + const isComponentDrag = draggedTagName !== null + + const handleDragOver = (event: DragEvent) => { + if (!isComponentDrag) return + event.preventDefault() + event.dataTransfer.dropEffect = 'copy' + } + + const handleDragEnter = (event: DragEvent) => { + if (!isComponentDrag) return + event.preventDefault() + dragDepth.current += 1 + setIsDropTarget(true) + } + + const handleDragLeave = () => { + if (!isComponentDrag) return + dragDepth.current -= 1 + if (dragDepth.current <= 0) { + dragDepth.current = 0 + setIsDropTarget(false) + } + } + + const handleDrop = (event: DragEvent) => { + if (!draggedTagName) return + event.preventDefault() + dragDepth.current = 0 + setIsDropTarget(false) + onDropComponent(filepath, draggedTagName) + } + const handleOpenInStudio = (adapterName: string, adapterPosition: number) => { openInStudio(navigate, { adapterName, filepath, adapterPosition }) } @@ -40,45 +97,95 @@ export default function ConfigurationFileTile({ setShowDeleteDialog(false) } + const hasContent = adapterNames.length > 0 || nonCanvasComponents.length > 0 + + let dropZoneClasses = 'border-border' + if (isDropTarget) { + dropZoneClasses = 'border-foreground-active ring-foreground-active border-dashed ring-2' + } else if (isComponentDrag) { + dropZoneClasses = 'border-foreground-active/50 border-dashed' + } + + let componentList + if (loadingComponents && adapterNames.length === 0) { + componentList = ( +
    + +
    + ) + } else if (hasContent) { + componentList = ( +
      + {adapterNames.map((adapterName, adapterPosition) => ( + onConfigureAdapter(filepath, adapterName, adapterPosition)} + onOpenInStudio={handleOpenInStudio} + /> + ))} + {nonCanvasComponents.map((component) => ( + onEditComponent(filepath, component)} + /> + ))} +
    + ) + } else { + componentList =
    No adapters or components found
    + } + return ( -
    -
    -

    - {relativePath} -

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

    + {relativePath} +

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

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

    -
    -
      - {adapterNames.map((name, index) => ( - - ))} -
    -
    -
    - ) : ( -
    No adapters found
    - )} +
    {componentList}
    -
    +
    } label="Open in Editor" onClick={handleOpenInEditor} /> + } + label="Add non-canvas component" + onClick={() => onAddComponent(filepath)} + />
    {showDeleteDialog && ( @@ -92,24 +199,3 @@ export default function ConfigurationFileTile({
    ) } - -type AdapterListItemProperties = { - name: string - adapterPosition: number - onOpenInStudio: (name: string, adapterPosition: number) => void -} - -function AdapterListItem({ name, adapterPosition, onOpenInStudio }: Readonly) { - return ( -
  • - - {name} - - } - label="Open in Studio" - onClick={() => onOpenInStudio(name, adapterPosition)} - /> -
  • - ) -} diff --git a/src/main/frontend/app/routes/configurations/configuration-overview.tsx b/src/main/frontend/app/routes/configurations/configuration-overview.tsx index 2caecbc1..646b462e 100644 --- a/src/main/frontend/app/routes/configurations/configuration-overview.tsx +++ b/src/main/frontend/app/routes/configurations/configuration-overview.tsx @@ -2,16 +2,35 @@ import { deleteFile } from '~/services/file-service' import { useProjectStore } from '~/stores/project-store' import ConfigurationFileTile from './configuration-file-tile' import ArrowLeftIcon from '/icons/solar/Alt Arrow Left.svg?react' +import ListIcon from '/icons/solar/List.svg?react' +import WidgetIcon from '/icons/solar/Widget.svg?react' import { useNavigate } from 'react-router' -import AddConfigurationTile from './add-configuration-tile' -import { useState, useEffect, useCallback, type ChangeEvent, useMemo } from 'react' -import AddConfigurationModal from './add-configuration-modal' +import AddConfigurationFileTile from './add-configuration-file-tile' +import React, { type ChangeEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react' +import AddConfigurationFileModal from './add-configuration-file-modal' import LoadingSpinner from '~/components/loading-spinner' 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 '~/components/sidebars-layout/sidebar-layout-store' +import NonCanvasComponentContext, { type NonCanvasComponentEditorState } from './non-canvas-component-context' +import AdapterContext, { type AdapterEditorState } from './adapter-context' +import NonCanvasComponentPalette from './non-canvas-component-palette' +import AddNonCanvasComponentMenu from './add-non-canvas-component-menu' +import type { NonCanvasComponent } from '~/services/non-canvas-component-service' +import { useNonCanvasComponents } from './use-non-canvas-components' import { relativeTo } from '~/utils/path-utils' +import { isRootConfiguration } from './configuration-utils' +import IconButton from '~/components/inputs/icon-button' +import { ActiveIconButton } from '~/components/inputs/active-icon-button' + +const SIDEBAR_NAME = 'configuration-overview' + +type TileView = 'list' | 'grid' type ConfigurationFile = { path: string @@ -44,6 +63,92 @@ export default function ConfigurationOverview() { const [isLoading, setIsLoading] = useState(false) const [searchQuery, setSearchQuery] = useState('') const [debouncedQuery, setDebouncedQuery] = useState(searchQuery) + const [editor, setEditor] = useState(null) + const [adapterEditor, setAdapterEditor] = useState(null) + const [editorName, setEditorName] = useState('') + const [addMenuConfigPath, setAddMenuConfigPath] = useState(null) + const [draggedComponentTagName, setDraggedComponentTagName] = useState(null) + const [tileView, setTileView] = useState('list') + + const sidebarVisibleBeforeEditRef = useRef(true) + + const setSidebarVisible = useSidebarStore((state) => state.setVisible) + const getSidebarVisibility = useSidebarStore((state) => state.getVisibility) + + const captureSidebarVisibility = useCallback(() => { + sidebarVisibleBeforeEditRef.current = getSidebarVisibility(SIDEBAR_NAME)?.[SidebarSide.RIGHT] ?? true + }, [getSidebarVisibility]) + + const openEditor = useCallback( + (state: NonCanvasComponentEditorState) => { + setAdapterEditor(null) + setEditor(state) + setEditorName(state.initialAttributes?.name ?? '') + setSidebarVisible(SIDEBAR_NAME, SidebarSide.RIGHT, true) + }, + [setSidebarVisible], + ) + + const openAdapterEditor = useCallback( + (state: AdapterEditorState) => { + setEditor(null) + setAdapterEditor(state) + setEditorName(state.adapterName) + setSidebarVisible(SIDEBAR_NAME, SidebarSide.RIGHT, true) + }, + [setSidebarVisible], + ) + + const closeEditor = useCallback(() => { + setEditor(null) + setAdapterEditor(null) + setEditorName('') + }, []) + + const handleAddComponent = useCallback((configPath: string) => { + setAddMenuConfigPath(configPath) + }, []) + + const handleSelectComponentType = useCallback( + (tagName: string) => { + if (!addMenuConfigPath) return + captureSidebarVisibility() + openEditor({ mode: 'add', configPath: addMenuConfigPath, tagName }) + setAddMenuConfigPath(null) + }, + [addMenuConfigPath, openEditor, captureSidebarVisibility], + ) + + const handleEditComponent = useCallback( + (configurationPath: string, component: NonCanvasComponent) => { + captureSidebarVisibility() + openEditor({ + mode: 'edit', + configPath: configurationPath, + tagName: component.tagName, + index: component.index, + initialAttributes: component.attributes, + }) + }, + [openEditor, captureSidebarVisibility], + ) + + const handleConfigureAdapter = useCallback( + (configurationPath: string, adapterName: string, adapterPosition: number) => { + openAdapterEditor({ configPath: configurationPath, adapterName, adapterPosition }) + }, + [openAdapterEditor], + ) + + const handleDropComponent = useCallback( + (configurationPath: string, tagName: string) => { + captureSidebarVisibility() + setDraggedComponentTagName(null) + setSidebarVisible(SIDEBAR_NAME, SidebarSide.RIGHT, false) + openEditor({ mode: 'add', configPath: configurationPath, tagName }) + }, + [openEditor, setSidebarVisible, captureSidebarVisibility], + ) const loadTree = useCallback( (signal?: AbortSignal) => { @@ -104,27 +209,52 @@ export default function ConfigurationOverview() { return () => clearTimeout(handler) }, [searchQuery]) - const filesWithAdapters = useMemo((): ConfigurationFile[] => { - return configFiles - .filter((file) => file.adapterNames && file.adapterNames.length > 0) - .map((file) => ({ - path: file.path, - relativePath: file.relativePath, - adapterNames: file.adapterNames!, - })) + const allConfigFiles = useMemo((): ConfigurationFile[] => { + const files = configFiles.map((file) => ({ + path: file.path, + relativePath: file.relativePath, + adapterNames: file.adapterNames ?? [], + })) + + return files.toSorted((a, b) => { + const aIsRoot = isRootConfiguration(a.relativePath) + const bIsRoot = isRootConfiguration(b.relativePath) + if (aIsRoot === bIsRoot) return 0 + return aIsRoot ? -1 : 1 + }) }, [configFiles]) const filteredConfigurationFiles = useMemo(() => { - if (!debouncedQuery.trim()) return filesWithAdapters + if (!debouncedQuery.trim()) return allConfigFiles const query = debouncedQuery.toLowerCase() - return filesWithAdapters.filter((file) => { + return allConfigFiles.filter((file) => { const matchesFile = file.relativePath.toLowerCase().includes(query) const matchesAdapter = file.adapterNames.some((adapter) => adapter.toLowerCase().includes(query)) return matchesFile || matchesAdapter }) - }, [filesWithAdapters, debouncedQuery]) + }, [allConfigFiles, debouncedQuery]) + + const configurationPaths = useMemo(() => allConfigFiles.map((file) => file.path), [allConfigFiles]) + const { componentsByPath, loadingByPath, replaceComponents } = useNonCanvasComponents( + currentConfigurationProject?.name ?? '', + configurationPaths, + ) + + const handleComponentSaved = useCallback( + (configurationPath: string, components: NonCanvasComponent[]) => { + replaceComponents(configurationPath, components) + closeEditor() + setSidebarVisible(SIDEBAR_NAME, SidebarSide.RIGHT, sidebarVisibleBeforeEditRef.current) + }, + [replaceComponents, closeEditor, setSidebarVisible], + ) + + const handleAdapterChanged = useCallback(() => { + closeEditor() + loadTree() + }, [closeEditor, loadTree]) if (!currentConfigurationProject) { return ( @@ -145,40 +275,141 @@ export default function ConfigurationOverview() { ) } + let sidebarTitle = 'Components' + if (adapterEditor) { + sidebarTitle = 'Adapter' + } else if (editor) { + if (editorName) { + sidebarTitle = editor.tagName + } else { + sidebarTitle = editor.mode === 'add' ? `Add ${editor.tagName}` : editor.tagName + } + } + + let sidebarContent + if (adapterEditor) { + sidebarContent = ( + + ) + } else if (editor) { + sidebarContent = ( + handleComponentSaved(editor.configPath, components)} + onClose={closeEditor} + onNameChange={setEditorName} + /> + ) + } else { + sidebarContent = ( + setDraggedComponentTagName(null)} + /> + ) + } + return ( -
    -
    navigate('/')}> - -

    Switch configuration

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

    Configuration Overview

    -
    -

    - Configuration files within {currentConfigurationProject.name} -

    - -
    + <> +
    +
    +
    + + navigate('/')}> + + + + Switch configuration + + +

    + {currentConfigurationProject.name ?? "Configuration name can't be retrieved"} +

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

    This component has no configurable attributes.

    + )} + + {orderedKeys.map((key) => ( + handleChange(key, value)} + label={key} + attribute={attributeDefinitions[key] ?? EMPTY_ATTRIBUTE} + enumOptions={makeEnumOptions(attributeDefinitions[key] ?? EMPTY_ATTRIBUTE)} + elements={elements ?? null} + /> + ))} + + {restKeys.length > 0 && ( +
    + +
    + )} +
    +
    + + +
    + ) +} diff --git a/src/main/frontend/app/routes/configurations/non-canvas-component-palette.tsx b/src/main/frontend/app/routes/configurations/non-canvas-component-palette.tsx new file mode 100644 index 00000000..e6cd1104 --- /dev/null +++ b/src/main/frontend/app/routes/configurations/non-canvas-component-palette.tsx @@ -0,0 +1,73 @@ +import { useMemo, useState, type ChangeEvent, type DragEvent } from 'react' +import { useFFDoc } from '@frankframework/doc-library-react' +import Search from '~/components/search/search' +import LoadingSpinner from '~/components/loading-spinner' +import { useFrankConfigXsd } from '~/providers/frankconfig-xsd-provider' +import { getElementTypeFromName } from '~/routes/studio/node-translator-module' +import { getAddableNonCanvasComponentNames } from '~/services/non-canvas-component-service' + +/** + * Palette of non-canvas components that can be dragged onto a configuration tile to add them. + */ +export default function NonCanvasComponentPalette({ + onDragStart, + onDragEnd, +}: { + onDragStart?: (tagName: string) => void + onDragEnd?: () => void +}) { + const { elements, isLoading } = useFFDoc() + const { xsdContent } = useFrankConfigXsd() + const [search, setSearch] = useState('') + + const addableNames = useMemo(() => getAddableNonCanvasComponentNames(xsdContent, elements), [xsdContent, elements]) + + const filteredNames = useMemo( + () => addableNames.filter((name) => name.toLowerCase().includes(search.toLowerCase())), + [addableNames, search], + ) + + const handleSearchChange = (event: ChangeEvent) => setSearch(event.target.value) + + const handleDragStart = (tagName: string) => (event: DragEvent) => { + event.dataTransfer.setData('text/plain', tagName) + event.dataTransfer.effectAllowed = 'copy' + onDragStart?.(tagName) + } + + const handleDragEnd = () => onDragEnd?.() + + return ( +
    + + + {isLoading || !elements || !xsdContent ? ( +
    + +
    + ) : ( +
      + {filteredNames.length === 0 ? ( +
    • + {addableNames.length === 0 ? 'No addable components found.' : 'No results found.'} +
    • + ) : ( + filteredNames.map((name) => ( +
    • + {name} +
    • + )) + )} +
    + )} +
    + ) +} diff --git a/src/main/frontend/app/routes/configurations/use-non-canvas-components.ts b/src/main/frontend/app/routes/configurations/use-non-canvas-components.ts new file mode 100644 index 00000000..a4a7fc56 --- /dev/null +++ b/src/main/frontend/app/routes/configurations/use-non-canvas-components.ts @@ -0,0 +1,48 @@ +import { useCallback, useEffect, useState } from 'react' +import { + getNonCanvasComponentsFromConfiguration, + type NonCanvasComponent, +} from '~/services/non-canvas-component-service' + +type NonCanvasComponentsState = { + componentsByPath: Record + loadingByPath: Record + replaceComponents: (configurationPath: string, components: NonCanvasComponent[]) => void +} + +export function useNonCanvasComponents(projectName: string, configurationPaths: string[]): NonCanvasComponentsState { + const [componentsByPath, setComponentsByPath] = useState>({}) + const [loadingByPath, setLoadingByPath] = useState>({}) + const pathsKey = JSON.stringify(configurationPaths) + + useEffect(() => { + const paths: string[] = JSON.parse(pathsKey) + const controller = new AbortController() + + setLoadingByPath(Object.fromEntries(paths.map((path) => [path, true]))) + + for (const path of paths) { + getNonCanvasComponentsFromConfiguration(projectName, path, controller.signal) + .then((components) => { + if (controller.signal.aborted) return + setComponentsByPath((previous) => ({ ...previous, [path]: components })) + }) + .catch(() => { + if (controller.signal.aborted) return + setComponentsByPath((previous) => ({ ...previous, [path]: [] })) + }) + .finally(() => { + if (controller.signal.aborted) return + setLoadingByPath((previous) => ({ ...previous, [path]: false })) + }) + } + + return () => controller.abort() + }, [projectName, pathsKey]) + + const replaceComponents = useCallback((configurationPath: string, components: NonCanvasComponent[]) => { + setComponentsByPath((previous) => ({ ...previous, [configurationPath]: components })) + }, []) + + return { componentsByPath, loadingByPath, replaceComponents } +} diff --git a/src/main/frontend/app/routes/editor/editor.tsx b/src/main/frontend/app/routes/editor/editor.tsx index 2659d44e..86e57908 100644 --- a/src/main/frontend/app/routes/editor/editor.tsx +++ b/src/main/frontend/app/routes/editor/editor.tsx @@ -19,7 +19,7 @@ import SegmentedButton from '~/components/inputs/segmented-button' import SidebarContentClose from '~/components/sidebars-layout/sidebar-content-close' import SidebarHeader from '~/components/sidebars-layout/sidebar-header' import SidebarLayout from '~/components/sidebars-layout/sidebar-layout' -import { SidebarSide } from '~/stores/sidebar-layout-store' +import { SidebarSide } from '~/components/sidebars-layout/sidebar-layout-store' import EditorTabs from '~/components/tabs/editor-tabs' import { SaveStatusIndicator } from '~/components/save-status-indicator' import { useSaveStatusStore } from '~/stores/save-status-store' diff --git a/src/main/frontend/app/routes/help/help.tsx b/src/main/frontend/app/routes/help/help.tsx index 7ff325cc..0c37b1aa 100644 --- a/src/main/frontend/app/routes/help/help.tsx +++ b/src/main/frontend/app/routes/help/help.tsx @@ -2,7 +2,7 @@ import '/styles/markdown.css' import { useParams } from 'react-router' import HelpTopicTreeItems, { type HelpTopicTreeItem } from './help-topic-tree-items' import SidebarContentClose from '~/components/sidebars-layout/sidebar-content-close' -import { SidebarSide } from '~/stores/sidebar-layout-store' +import { SidebarSide } from '~/components/sidebars-layout/sidebar-layout-store' import SidebarLayout from '~/components/sidebars-layout/sidebar-layout' import SidebarHeader from '~/components/sidebars-layout/sidebar-header' import HelpTopics from '~/routes/help/help-topics' diff --git a/src/main/frontend/app/routes/settings/settings.tsx b/src/main/frontend/app/routes/settings/settings.tsx index 504524b6..07a0e3fc 100644 --- a/src/main/frontend/app/routes/settings/settings.tsx +++ b/src/main/frontend/app/routes/settings/settings.tsx @@ -1,7 +1,7 @@ import '/styles/markdown.css' import SettingsMenuItems, { type SettingsMenuItem } from './settings-menu-items' import SidebarContentClose from '~/components/sidebars-layout/sidebar-content-close' -import { SidebarSide } from '~/stores/sidebar-layout-store' +import { SidebarSide } from '~/components/sidebars-layout/sidebar-layout-store' import SidebarLayout from '~/components/sidebars-layout/sidebar-layout' import SidebarHeader from '~/components/sidebars-layout/sidebar-header' import SettingsMenu from '~/routes/settings/settings-menu' diff --git a/src/main/frontend/app/routes/studio/canvas/flow.tsx b/src/main/frontend/app/routes/studio/canvas/flow.tsx index 642c8f49..4be437be 100644 --- a/src/main/frontend/app/routes/studio/canvas/flow.tsx +++ b/src/main/frontend/app/routes/studio/canvas/flow.tsx @@ -55,7 +55,7 @@ import { useSettingsStore } from '~/stores/settings-store' import { useShortcut } from '~/hooks/use-shortcut' import LightbulbIcon from '/icons/solar/Lightbulb.svg?react' import CanvasContextMenu from '~/components/flow/canvas-context-menu' -import { useSidebarStore, SidebarSide } from '~/stores/sidebar-layout-store' +import { useSidebarStore, SidebarSide } from '~/components/sidebars-layout/sidebar-layout-store' import { openInEditorAtElement } from '~/actions/navigationActions' import HandleMenu from '~/routes/studio/canvas/nodetypes/components/handle-menu' 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 a45677d9..66348443 100644 --- a/src/main/frontend/app/routes/studio/context/node-context.tsx +++ b/src/main/frontend/app/routes/studio/context/node-context.tsx @@ -5,6 +5,7 @@ import { useShortcut } from '~/hooks/use-shortcut' import useFlowStore, { isExitNode, isFrankNode } from '~/stores/flow-store' import Button from '~/components/inputs/button' import Toggle from '~/components/inputs/toggle' +import ContextEditorFooter from '~/components/context-editor-footer' import { useShallow } from 'zustand/react/shallow' import ContextInput from './context-input' import { findChildRecursive } from '~/stores/child-utilities' @@ -402,29 +403,17 @@ export default function NodeContext({
    -
    -
    - - -
    - - - -
    -
    - - {!canSave && errorMessage &&

    {errorMessage}

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