From 877bcc101fdc415754676efebc67bdad5d25a816 Mon Sep 17 00:00:00 2001 From: stijnpotters Date: Tue, 26 May 2026 12:16:21 +0200 Subject: [PATCH 1/3] Add folder creation functionality to directory picker --- .../directory-picker/create-folder-row.tsx | 67 +++++++++++++++++++ .../directory-picker/directory-picker.tsx | 47 +++++++++---- .../app/services/filesystem-service.ts | 4 ++ .../flow/filesystem/FilesystemController.java | 13 ++++ 4 files changed, 118 insertions(+), 13 deletions(-) create mode 100644 src/main/frontend/app/components/directory-picker/create-folder-row.tsx diff --git a/src/main/frontend/app/components/directory-picker/create-folder-row.tsx b/src/main/frontend/app/components/directory-picker/create-folder-row.tsx new file mode 100644 index 00000000..8b1a0bf5 --- /dev/null +++ b/src/main/frontend/app/components/directory-picker/create-folder-row.tsx @@ -0,0 +1,67 @@ +import React, { useState } from 'react' +import FolderIcon from '/icons/solar/Folder.svg?react' +import Button from '~/components/inputs/button' +import Input from '~/components/inputs/input' + +interface CreateFolderRowProperties { + onConfirm: (name: string) => Promise + onCancel: () => void +} + +export default function CreateFolderRow({ onConfirm, onCancel }: Readonly) { + const [name, setName] = useState('') + const [error, setError] = useState(null) + const [submitting, setSubmitting] = useState(false) + + const handleSubmit = async () => { + const trimmed = name.trim() + if (!trimmed) { + setError('Folder name cannot be empty') + return + } + setSubmitting(true) + setError(null) + try { + await onConfirm(trimmed) + } catch { + setSubmitting(false) + } + } + + const handleKeyDown = (event: React.KeyboardEvent) => { + if (event.key === 'Enter') void handleSubmit() + else if (event.key === 'Escape') onCancel() + } + + return ( +
+
+ + { + setName(changeEvent.target.value) + setError(null) + }} + onKeyDown={handleKeyDown} + placeholder="New folder name" + inputClassName="py-0.5 text-sm" + wrapperClassName="flex-1" + disabled={submitting} + autoFocus + /> + + +
+ {error &&

{error}

} +
+ ) +} diff --git a/src/main/frontend/app/components/directory-picker/directory-picker.tsx b/src/main/frontend/app/components/directory-picker/directory-picker.tsx index 0a7f93f3..e5ecf00c 100644 --- a/src/main/frontend/app/components/directory-picker/directory-picker.tsx +++ b/src/main/frontend/app/components/directory-picker/directory-picker.tsx @@ -4,6 +4,7 @@ import { filesystemService } from '~/services/filesystem-service' import type { FilesystemEntry } from '~/types/filesystem.types' import { ApiError } from '~/utils/api' import Button from '../inputs/button' +import CreateFolderRow from './create-folder-row' interface DirectoryPickerProperties { isOpen: boolean @@ -26,11 +27,13 @@ export default function DirectoryPicker({ const [selectedEntry, setSelectedEntry] = useState(null) const [loading, setLoading] = useState(false) const [error, setError] = useState(null) + const [isCreatingFolder, setIsCreatingFolder] = useState(false) const loadEntries = useCallback(async (path: string) => { setLoading(true) setError(null) setSelectedEntry(null) + setIsCreatingFolder(false) try { const result = await filesystemService.browse(path) setEntries(result.entries) @@ -47,10 +50,6 @@ export default function DirectoryPicker({ } }, []) - const handleNavigateUp = () => { - loadEntries(parentPath) - } - useEffect(() => { if (isOpen) { setSelectedEntry(null) @@ -60,8 +59,6 @@ export default function DirectoryPicker({ if (!isOpen) return null - const canGoUp = parentPath !== '' - const handleClick = (entry: FilesystemEntry) => { setSelectedEntry(entry.path) } @@ -70,6 +67,15 @@ export default function DirectoryPicker({ loadEntries(entry.path) } + const handleCreateFolder = async (folderName: string) => { + const separator = currentPath.includes('\\') ? '\\' : '/' + const newPath = `${currentPath}${separator}${folderName}` + await filesystemService.createDirectory(newPath) + await loadEntries(currentPath) + setSelectedEntry(newPath) + } + + const canGoUp = parentPath !== '' const activePath = selectedEntry ?? currentPath return ( @@ -87,21 +93,32 @@ export default function DirectoryPicker({
- {currentPath || rootLabel} + {currentPath || rootLabel} + {currentPath && !isCreatingFolder && ( + + )}
-
- {loading &&

Loading...

} +
+ {loading &&

Loading…

} {error &&

{error}

} - {!loading && !error && entries.length === 0 && ( + + {!loading && !error && entries.length === 0 && !isCreatingFolder && (

No subdirectories

)} + {!loading && !error && entries.map((entry) => ( @@ -113,8 +130,8 @@ export default function DirectoryPicker({ selectedEntry === entry.path ? 'bg-backdrop font-medium' : 'hover:bg-backdrop/50' }`} > - - + + {entry.projectRoot && ( )} @@ -122,6 +139,10 @@ export default function DirectoryPicker({ {entry.name} ))} + + {isCreatingFolder && ( + setIsCreatingFolder(false)} /> + )}
diff --git a/src/main/frontend/app/services/filesystem-service.ts b/src/main/frontend/app/services/filesystem-service.ts index e59cb5f0..6a20654f 100644 --- a/src/main/frontend/app/services/filesystem-service.ts +++ b/src/main/frontend/app/services/filesystem-service.ts @@ -10,4 +10,8 @@ export const filesystemService = { const result = await this.browse(path) return result.resolvedPath }, + + async createDirectory(path: string): Promise { + return apiFetch(`/filesystem/mkdir?path=${encodeURIComponent(path)}`, { method: 'POST' }) + }, } diff --git a/src/main/java/org/frankframework/flow/filesystem/FilesystemController.java b/src/main/java/org/frankframework/flow/filesystem/FilesystemController.java index 5ba77fc7..2802881f 100644 --- a/src/main/java/org/frankframework/flow/filesystem/FilesystemController.java +++ b/src/main/java/org/frankframework/flow/filesystem/FilesystemController.java @@ -5,6 +5,7 @@ import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; @@ -28,4 +29,16 @@ public ResponseEntity browse(@RequestParam(required = false, defau return ResponseEntity.status(HttpStatus.FORBIDDEN).build(); } } + + @PostMapping("/mkdir") + public ResponseEntity mkdir(@RequestParam String path) throws IOException { + try { + fileSystemStorage.createProjectDirectory(path); + return ResponseEntity.ok().build(); + } catch (AccessDeniedException _) { + return ResponseEntity.status(HttpStatus.FORBIDDEN).build(); + } catch (SecurityException _) { + return ResponseEntity.status(HttpStatus.BAD_REQUEST).build(); + } + } } From 2714e2a25b49af4d7ad772648909293fb649fc61 Mon Sep 17 00:00:00 2001 From: stijnpotters Date: Tue, 26 May 2026 14:52:18 +0200 Subject: [PATCH 2/3] Replace CreateFolderRow with NameInputDialog for folder creation --- .../directory-picker/create-folder-row.tsx | 67 ------------------- .../directory-picker/directory-picker.tsx | 24 +++++-- .../file-structure/name-input-dialog.tsx | 2 +- 3 files changed, 18 insertions(+), 75 deletions(-) delete mode 100644 src/main/frontend/app/components/directory-picker/create-folder-row.tsx diff --git a/src/main/frontend/app/components/directory-picker/create-folder-row.tsx b/src/main/frontend/app/components/directory-picker/create-folder-row.tsx deleted file mode 100644 index 8b1a0bf5..00000000 --- a/src/main/frontend/app/components/directory-picker/create-folder-row.tsx +++ /dev/null @@ -1,67 +0,0 @@ -import React, { useState } from 'react' -import FolderIcon from '/icons/solar/Folder.svg?react' -import Button from '~/components/inputs/button' -import Input from '~/components/inputs/input' - -interface CreateFolderRowProperties { - onConfirm: (name: string) => Promise - onCancel: () => void -} - -export default function CreateFolderRow({ onConfirm, onCancel }: Readonly) { - const [name, setName] = useState('') - const [error, setError] = useState(null) - const [submitting, setSubmitting] = useState(false) - - const handleSubmit = async () => { - const trimmed = name.trim() - if (!trimmed) { - setError('Folder name cannot be empty') - return - } - setSubmitting(true) - setError(null) - try { - await onConfirm(trimmed) - } catch { - setSubmitting(false) - } - } - - const handleKeyDown = (event: React.KeyboardEvent) => { - if (event.key === 'Enter') void handleSubmit() - else if (event.key === 'Escape') onCancel() - } - - return ( -
-
- - { - setName(changeEvent.target.value) - setError(null) - }} - onKeyDown={handleKeyDown} - placeholder="New folder name" - inputClassName="py-0.5 text-sm" - wrapperClassName="flex-1" - disabled={submitting} - autoFocus - /> - - -
- {error &&

{error}

} -
- ) -} diff --git a/src/main/frontend/app/components/directory-picker/directory-picker.tsx b/src/main/frontend/app/components/directory-picker/directory-picker.tsx index e5ecf00c..106a37f3 100644 --- a/src/main/frontend/app/components/directory-picker/directory-picker.tsx +++ b/src/main/frontend/app/components/directory-picker/directory-picker.tsx @@ -1,10 +1,10 @@ import { useCallback, useEffect, useState } from 'react' import FolderIcon from '/icons/solar/Folder.svg?react' +import NameInputDialog from '~/components/file-structure/name-input-dialog' import { filesystemService } from '~/services/filesystem-service' import type { FilesystemEntry } from '~/types/filesystem.types' import { ApiError } from '~/utils/api' import Button from '../inputs/button' -import CreateFolderRow from './create-folder-row' interface DirectoryPickerProperties { isOpen: boolean @@ -68,11 +68,17 @@ export default function DirectoryPicker({ } const handleCreateFolder = async (folderName: string) => { - const separator = currentPath.includes('\\') ? '\\' : '/' - const newPath = `${currentPath}${separator}${folderName}` - await filesystemService.createDirectory(newPath) - await loadEntries(currentPath) - setSelectedEntry(newPath) + const basePath = selectedEntry ?? currentPath + const separator = basePath.includes('\\') ? '\\' : '/' + const newPath = `${basePath}${separator}${folderName}` + setIsCreatingFolder(false) + try { + await filesystemService.createDirectory(newPath) + await loadEntries(basePath) + setSelectedEntry(newPath) + } catch { + setError('Failed to create folder') + } } const canGoUp = parentPath !== '' @@ -141,7 +147,11 @@ export default function DirectoryPicker({ ))} {isCreatingFolder && ( - setIsCreatingFolder(false)} /> + void handleCreateFolder(name)} + onCancel={() => setIsCreatingFolder(false)} + /> )}
diff --git a/src/main/frontend/app/components/file-structure/name-input-dialog.tsx b/src/main/frontend/app/components/file-structure/name-input-dialog.tsx index fb3e0299..31396a2d 100644 --- a/src/main/frontend/app/components/file-structure/name-input-dialog.tsx +++ b/src/main/frontend/app/components/file-structure/name-input-dialog.tsx @@ -63,7 +63,7 @@ export default function NameInputDialog({ return createPortal(
From f67f3460310feb6dc939c751bbcacf9a8b50293c Mon Sep 17 00:00:00 2001 From: stijnpotters Date: Tue, 26 May 2026 15:24:31 +0200 Subject: [PATCH 3/3] Add submitLabel prop to NameInputDialog for customizable button text --- .../app/components/directory-picker/directory-picker.tsx | 1 + .../app/components/file-structure/file-tree-dialogs.tsx | 1 + .../app/components/file-structure/name-input-dialog.tsx | 4 +++- .../components/file-structure/studio-file-tree-dialogs.tsx | 1 + .../components/file-structure/use-file-tree-context-menu.ts | 3 +++ .../app/components/file-structure/use-studio-context-menu.ts | 4 ++++ 6 files changed, 13 insertions(+), 1 deletion(-) diff --git a/src/main/frontend/app/components/directory-picker/directory-picker.tsx b/src/main/frontend/app/components/directory-picker/directory-picker.tsx index 106a37f3..32b8c75f 100644 --- a/src/main/frontend/app/components/directory-picker/directory-picker.tsx +++ b/src/main/frontend/app/components/directory-picker/directory-picker.tsx @@ -149,6 +149,7 @@ export default function DirectoryPicker({ {isCreatingFolder && ( void handleCreateFolder(name)} onCancel={() => setIsCreatingFolder(false)} /> diff --git a/src/main/frontend/app/components/file-structure/file-tree-dialogs.tsx b/src/main/frontend/app/components/file-structure/file-tree-dialogs.tsx index 5de34c46..9d92d427 100644 --- a/src/main/frontend/app/components/file-structure/file-tree-dialogs.tsx +++ b/src/main/frontend/app/components/file-structure/file-tree-dialogs.tsx @@ -49,6 +49,7 @@ export default function FileTreeDialogs({ = BASE_NAME interface NameInputDialogProps { title: string initialValue?: string + submitLabel?: string onSubmit: (name: string) => void onCancel: () => void patterns?: Record @@ -33,6 +34,7 @@ interface NameInputDialogProps { export default function NameInputDialog({ title, initialValue = '', + submitLabel = 'OK', onSubmit, onCancel, patterns = FOLDER_OR_ADAPTER_NAME_PATTERNS, @@ -82,7 +84,7 @@ export default function NameInputDialog({ Cancel
diff --git a/src/main/frontend/app/components/file-structure/studio-file-tree-dialogs.tsx b/src/main/frontend/app/components/file-structure/studio-file-tree-dialogs.tsx index 684c0e39..6ed4d8c5 100644 --- a/src/main/frontend/app/components/file-structure/studio-file-tree-dialogs.tsx +++ b/src/main/frontend/app/components/file-structure/studio-file-tree-dialogs.tsx @@ -51,6 +51,7 @@ export default function StudioFileTreeDialogs({ void patterns?: Record } @@ -118,6 +119,7 @@ export function useFileTreeContextMenu({ setNameDialog({ title: 'New File', + submitLabel: 'Create', onSubmit: async (name: string) => { if (!ensureHasCorrectExtension(name)) { showErrorToast(`Filename must have one of the following extensions: ${ALLOWED_EXTENSIONS.join(', ')}`) @@ -148,6 +150,7 @@ export function useFileTreeContextMenu({ setNameDialog({ title: 'New Folder', + submitLabel: 'Create', onSubmit: async (name: string) => { try { await createFolderInProject(projectName, `${parentPath}/${name}`) diff --git a/src/main/frontend/app/components/file-structure/use-studio-context-menu.ts b/src/main/frontend/app/components/file-structure/use-studio-context-menu.ts index 82aedfe0..b3fb0aac 100644 --- a/src/main/frontend/app/components/file-structure/use-studio-context-menu.ts +++ b/src/main/frontend/app/components/file-structure/use-studio-context-menu.ts @@ -27,6 +27,7 @@ export interface StudioContextMenuState { export interface NameDialogState { title: string initialValue?: string + submitLabel?: string onSubmit: (name: string) => void patterns?: Record } @@ -171,6 +172,7 @@ export function useStudioContextMenu({ projectName, dataProvider }: UseStudioCon setNameDialog({ title: 'New Configuration File', + submitLabel: 'Create', onSubmit: async (name: string) => { const fileName = ensureXmlExtension(name) try { @@ -201,6 +203,7 @@ export function useStudioContextMenu({ projectName, dataProvider }: UseStudioCon setNameDialog({ title: 'New Adapter', + submitLabel: 'Create', onSubmit: async (name: string) => { try { await createAdapter(projectName, name, menu.path) @@ -224,6 +227,7 @@ export function useStudioContextMenu({ projectName, dataProvider }: UseStudioCon setNameDialog({ title: 'New Folder', + submitLabel: 'Create', onSubmit: async (name: string) => { try { await createFolderInProject(projectName, `${menu.folderPath}/${name}`)