diff --git a/src/main/frontend/app/routes/configurations/add-configuration-modal.tsx b/src/main/frontend/app/routes/configurations/add-configuration-modal.tsx index 1668a69b..3dc3c26e 100644 --- a/src/main/frontend/app/routes/configurations/add-configuration-modal.tsx +++ b/src/main/frontend/app/routes/configurations/add-configuration-modal.tsx @@ -93,7 +93,7 @@ export default function AddConfigurationModal({ onClick={handleClickedOutside} >
-

Add Configuration

+

Add File

Add a new configuration file.

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 845ccf07..cfc4ae0b 100644 --- a/src/main/frontend/app/routes/configurations/configuration-file-tile.tsx +++ b/src/main/frontend/app/routes/configurations/configuration-file-tile.tsx @@ -40,7 +40,7 @@ export default function ConfigurationFileTile({ return (
{/* Header */} -
+
{relativePath}
@@ -55,9 +55,9 @@ export default function ConfigurationFileTile({ {adapterNames.length > 0 ? ( <>

- Adapter{adapterNames.length == 1 ? '' : 's'} within this configuration: + Adapter{adapterNames.length == 1 ? '' : 's'} within this file

-
+
    {adapterNames.map((name, index) => ( +
  • {/* Adapter name – 2/3 */} {name} diff --git a/src/main/frontend/app/routes/configurations/configuration-overview.tsx b/src/main/frontend/app/routes/configurations/configuration-overview.tsx index 71fc20aa..a16831ed 100644 --- a/src/main/frontend/app/routes/configurations/configuration-overview.tsx +++ b/src/main/frontend/app/routes/configurations/configuration-overview.tsx @@ -11,7 +11,7 @@ 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 { toRelativePath } from '~/utils/path-utils' +import { normalizePath, toRelativePath } from '~/utils/path-utils' interface ConfigurationFile { path: string @@ -22,7 +22,7 @@ interface ConfigurationFile { function findConfigurationsDir(node: FileTreeNode | undefined | null): FileTreeNode | null { if (!node || !node.path) return null - const normalizedPath = node.path.replaceAll('\\', '/') + const normalizedPath = normalizePath(node.path) if (node.type === 'DIRECTORY' && normalizedPath.endsWith(`/src/main/configurations/${node.name}`)) { return node @@ -117,7 +117,7 @@ export default function ConfigurationOverview() { const xmlFiles = collectXmlFiles(configurationDirectory) return xmlFiles.map((file) => { - const relativePath = toRelativePath(file.path, 'src/main/configurations/') ?? file.name + const relativePath = toRelativePath(file.path, `${configurationDirectory.path}/`) ?? file.name return { ...file, relativePath, path: file.path } }) }, [tree, currentConfigurationProject]) @@ -185,13 +185,12 @@ export default function ConfigurationOverview() {

    Configuration Overview

    - Configuration files within src/main/configurations/ - {currentConfigurationProject.name}: + Configuration files within {currentConfigurationProject.name}

    -
    +
    {filteredConfigurationFiles.map((file) => ( void onCreate: (name: string, rootPath: string) => void - initialPath?: string + initialPath: string } const CONFIG_DIR = 'src/main/configurations' @@ -26,12 +26,12 @@ export default function NewConfigurationModal({ useEffect(() => { if (!isOpen || !isLocal) { - if (isOpen) setLocation(initialPath ?? '') + if (isOpen) setLocation(initialPath) return } filesystemService - .resolveNearestAccessiblePath(initialPath ?? '') + .resolveNearestAccessiblePath(initialPath) .then(setLocation) .catch(() => setLocation('')) }, [isOpen, isLocal, initialPath]) diff --git a/src/main/frontend/app/routes/projectlanding/project-landing.tsx b/src/main/frontend/app/routes/projectlanding/project-landing.tsx index ca3bfb16..70ca29a1 100644 --- a/src/main/frontend/app/routes/projectlanding/project-landing.tsx +++ b/src/main/frontend/app/routes/projectlanding/project-landing.tsx @@ -6,6 +6,7 @@ import { fetchInstanceConfigurations, type FFConfiguration } from '~/services/fr import { useProjectStore } from '~/stores/project-store' import { ApiError } from '~/utils/api' import { logApiError } from '~/utils/logger' +import {getParentPath, normalizePath} from '~/utils/path-utils' import ConfigurationRow from './configuration-row' import Search from '~/components/search/search' @@ -187,7 +188,7 @@ export default function ProjectLanding() { const projects = recentProjects ?? [] const filteredProjects = projects.filter((project) => project.name.toLowerCase().includes(searchTerm.toLowerCase())) - const lastRecentRootPath = projects[0]?.rootPath + const lastRecentRootPath = normalizePath(projects[0]?.rootPath ?? '') if (isLoading || isOpeningProject) return @@ -231,14 +232,14 @@ export default function ProjectLanding() { onClose={() => setIsModalOpen(false)} onCreate={onCreateProject} isLocal={isLocalEnvironment} - initialPath={lastRecentRootPath} + initialPath={getParentPath(lastRecentRootPath)} /> setIsCloneModalOpen(false)} onClone={onCloneProject} - initialPath={lastRecentRootPath} + initialPath={getParentPath(lastRecentRootPath)} /> {!isLocalEnvironment && ( setIsOpenPickerOpen(false)} rootLabel={rootLocationName} - initialPath={lastRecentRootPath} + initialPath={getParentPath(lastRecentRootPath)} />
    ) @@ -351,13 +352,13 @@ const ProjectList = ({ ) -const Toolbar = ({ onSearchChange }: { onSearchChange: (val: string) => void }) => ( +const Toolbar = ({ onSearchChange }: { onSearchChange: (value: string) => void }) => (
    Recent
    - onSearchChange(e.target.value)} /> + onSearchChange(event.target.value)} />
    ) diff --git a/src/main/frontend/app/utils/path-utils.ts b/src/main/frontend/app/utils/path-utils.ts index d9bc5aca..af12ef87 100644 --- a/src/main/frontend/app/utils/path-utils.ts +++ b/src/main/frontend/app/utils/path-utils.ts @@ -1,32 +1,19 @@ -import type { ConfigurationProject } from '~/types/project.types' - /** * Extracts the portion of a path after the first occurrence of a marker segment. * Returns null if the marker is not found. */ export function toRelativePath(absolutePath: string, marker: string): string | null { - const normalized = absolutePath.replaceAll('\\', '/') - const idx = normalized.indexOf(marker) - return idx === -1 ? null : normalized.slice(idx + marker.length) + const normalizedPath = normalizePath(absolutePath) + const normalizedMarker = normalizePath(marker) + const idx = normalizedPath.indexOf(marker) + return idx === -1 ? null : normalizedMarker.slice(idx + marker.length) } -/** - * Converts a file path to a project-relative path for display. - * Handles both local and cloud environments - */ -export function toProjectRelativePath(absolutePath: string, project: ConfigurationProject): string { - const path = absolutePath.replaceAll('\\', '/') - const root = project.rootPath.replaceAll('\\', '/').replace(/^\/+/, '').replace(/\/+$/, '') - const normalizedPath = path.replace(/^\/+/, '') - - if (normalizedPath === root) { - return `${project.name}/` - } - - const relative = toRelativePath(normalizedPath, `${root}/`) - if (relative !== null) { - return `${project.name}/${relative}` - } +export function normalizePath(path: string) { + return path.replaceAll('\\', '/') +} - return path +export function getParentPath(path: string): string { + if (!path) return path // Return empty string if path is empty, small optimization to avoid regex processing + return path.replace(/\/?[^/]*$/, '') } diff --git a/src/main/java/org/frankframework/flow/project/ConfigurationProjectController.java b/src/main/java/org/frankframework/flow/project/ConfigurationProjectController.java index 4255b25e..2f1d6d32 100644 --- a/src/main/java/org/frankframework/flow/project/ConfigurationProjectController.java +++ b/src/main/java/org/frankframework/flow/project/ConfigurationProjectController.java @@ -80,7 +80,8 @@ public ResponseEntity cloneProject(@RequestBody Configu } @PostMapping("/open") - public ResponseEntity openProject(@RequestBody ConfigurationProjectCreateDTO configurationProjectCreateDTO) throws IOException, ApiException { + public ResponseEntity openProject(@RequestBody ConfigurationProjectCreateDTO configurationProjectCreateDTO) + throws IOException, ApiException { ConfigurationProject configurationProject = configurationProjectService.openProjectFromDisk(configurationProjectCreateDTO.rootPath()); recentProjectsService.addRecentProject(configurationProject.getName(), configurationProject.getRootPath()); return ResponseEntity.ok(configurationProjectService.toDto(configurationProject)); diff --git a/src/main/java/org/frankframework/flow/project/ConfigurationProjectService.java b/src/main/java/org/frankframework/flow/project/ConfigurationProjectService.java index d6a20de6..6369cda3 100644 --- a/src/main/java/org/frankframework/flow/project/ConfigurationProjectService.java +++ b/src/main/java/org/frankframework/flow/project/ConfigurationProjectService.java @@ -34,8 +34,6 @@ @Log4j2 @Service public class ConfigurationProjectService { - private static final String CONFIGURATIONS_DIR = "src/main/configurations"; - private final FileSystemStorage fileSystemStorage; private final RecentProjectsService recentProjectsService; @@ -70,7 +68,7 @@ private List getProjectsFromRecentList() { ConfigurationProject configurationProject = loadProjectCached(recent.rootPath()); foundProjects.add(configurationProject); } catch (Exception _) { - log.debug("Recent project no longer valid: {}", recent.rootPath()); + log.debug("Recent project is no longer valid: {}", recent.rootPath()); } } return foundProjects; @@ -101,13 +99,16 @@ public ConfigurationProject getProject(String name) throws ApiException { return getProjects().stream() .filter(project -> project.getName().equals(name)) .findFirst() - .orElseThrow(() -> new ApiException("Project not found: " + name, HttpStatus.NOT_FOUND)); + .orElseThrow(() -> new ApiException("Project \"" + name +"\" not found", HttpStatus.NOT_FOUND)); } public ConfigurationProject createProjectOnDisk(ConfigurationProjectCreateDTO projectCreate) throws IOException { Path rootPath = Path.of(projectCreate.rootPath()); - String resolvedRootPath = rootPath.endsWith(CONFIGURATIONS_DIR) ? projectCreate.name() : CONFIGURATIONS_DIR + "/" + projectCreate.name(); - Path projectCreationPath = rootPath.resolve(resolvedRootPath); + Path projectCreationPath = rootPath.resolve(projectCreate.name()); + + if (Files.exists(projectCreationPath)) { + throw new ApiException("Project already exists at " + projectCreationPath, HttpStatus.NOT_FOUND); + } Path projectPath = fileSystemStorage.createProjectDirectory(projectCreationPath.toString()); ClassPathResource resource = new ClassPathResource("templates/default-configuration.xml"); @@ -125,7 +126,9 @@ public ConfigurationProject createProjectOnDisk(ConfigurationProjectCreateDTO pr public ConfigurationProject openProjectFromDisk(String path) throws IOException, ApiException { Path absolutePath = fileSystemStorage.toAbsolutePath(path); if (!Files.exists(absolutePath) || !Files.isDirectory(absolutePath)) { - throw new ApiException("Project not found at: " + path, HttpStatus.NOT_FOUND); + throw new ApiException("Project not found at \"" + path + "\"", HttpStatus.NOT_FOUND); + } else if (!absolutePath.resolve("Configuration.xml").toFile().exists()) { + throw new ApiException("Project doesn't seem to be a valid configuration or Configuration.xml might be missing", HttpStatus.BAD_REQUEST); } return loadProjectAndCache(path); } @@ -134,7 +137,7 @@ public ConfigurationProject cloneAndOpenProject(String repoUrl, String localPath Path targetDir = fileSystemStorage.toAbsolutePath(localPath); if (Files.exists(targetDir)) { - throw new IllegalArgumentException("Project already exists at: " + localPath); + throw new IllegalArgumentException("Project already exists at \"" + localPath + "\""); } try { @@ -152,9 +155,9 @@ public ConfigurationProject cloneAndOpenProject(String repoUrl, String localPath } catch (GitAPIException exception) { String msg = exception.getMessage() != null ? exception.getMessage().toLowerCase() : ""; if (msg.contains("auth") || msg.contains("not permitted") || msg.contains("403") || msg.contains("401")) { - throw new IllegalArgumentException("Clone failed — authentication error. Please provide a valid Personal Access Token (PAT)", exception); + throw new IllegalArgumentException("Cloning authentication error. Please provide a valid Personal Access Token (PAT)", exception); } - throw new IllegalArgumentException("Clone failed: " + exception.getMessage(), exception); + throw new IllegalArgumentException("Cloning failed: " + exception.getMessage(), exception); } ConfigurationProject configurationProject = loadProjectAndCache(targetDir.toString()); @@ -239,8 +242,7 @@ public ConfigurationProjectDTO toDto(ConfigurationProject configurationProject) Path absolutePath = fileSystemStorage.toAbsolutePath(configurationProject.getRootPath()); boolean isGitRepo = Files.isDirectory(absolutePath.resolve(".git")); - boolean hasStoredToken = - configurationProject.getGitToken() != null && !configurationProject.getGitToken().isBlank(); + boolean hasStoredToken = configurationProject.getGitToken() != null && !configurationProject.getGitToken().isBlank(); return new ConfigurationProjectDTO( configurationProject.getName(), diff --git a/src/test/java/org/frankframework/flow/project/ConfigurationProjectServiceTest.java b/src/test/java/org/frankframework/flow/project/ConfigurationProjectServiceTest.java index ccda6197..094f9dcb 100644 --- a/src/test/java/org/frankframework/flow/project/ConfigurationProjectServiceTest.java +++ b/src/test/java/org/frankframework/flow/project/ConfigurationProjectServiceTest.java @@ -408,9 +408,9 @@ void testOpenProjectFromDisk() throws Exception { String projectName = "manual_project"; Path projectDir = tempDir.resolve(projectName); - Files.createDirectories(projectDir.resolve("src/main/configurations")); + Files.createDirectories(projectDir); Files.writeString( - projectDir.resolve("src/main/configurations/TestConfig.xml"), + projectDir.resolve("Configuration.xml"), "", StandardCharsets.UTF_8 ); @@ -449,26 +449,6 @@ void testOpenProjectFromDiskThrowsWhenPathIsAFile() throws Exception { assertThrows(ApiException.class, () -> configurationProjectService.openProjectFromDisk(file.toString())); } - @Test - void testOpenProjectFromDiskLoadsEmptyProject_whenNoConfigurationsDir() throws Exception { - when(fileSystemStorage.toAbsolutePath(anyString())).thenAnswer(invocation -> { - String pathStr = invocation.getArgument(0); - Path path = Path.of(pathStr); - return path.isAbsolute() ? path : tempDir.resolve(pathStr); - }); - - Path projDir = tempDir.resolve("empty_proj"); - Files.createDirectory(projDir); - - ConfigurationProject configurationProject = configurationProjectService.openProjectFromDisk(projDir.toString()); - - assertNotNull(configurationProject); - assertEquals("empty_proj", configurationProject.getName()); - - ConfigurationProjectDTO dto = configurationProjectService.toDto(configurationProject); - assertTrue(dto.filepaths().isEmpty(), "No configurations dir means empty config list"); - } - @Test void testGetProjectsFromWorkspaceScan() throws Exception { when(fileSystemStorage.isLocalEnvironment()).thenReturn(false);