diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7c597f7a..e9f8b359 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -84,6 +84,9 @@ importers: '@mdx-js/react': specifier: ^3.1.1 version: 3.1.1(@types/react@19.2.14)(react@19.2.4) + '@prettier/plugin-xml': + specifier: ^3.4.1 + version: 3.4.2(prettier@3.8.1) '@react-router/dev': specifier: ^7.13.1 version: 7.13.1(@react-router/serve@7.13.1(react-router@7.13.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.9.3))(@types/node@20.19.37)(babel-plugin-macros@3.1.0)(jiti@2.6.1)(lightningcss@1.31.1)(react-router@7.13.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.9.3)(vite@6.4.1(@types/node@20.19.37)(jiti@2.6.1)(lightningcss@1.31.1)) @@ -770,6 +773,11 @@ packages: '@polka/url@1.0.0-next.29': resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==} + '@prettier/plugin-xml@3.4.2': + resolution: {integrity: sha512-/UyNlHfkuLXG6Ed85KB0WBF283xn2yavR+UtRibBRUcvEJId2DSLdGXwJ/cDa1X++SWDPzq3+GSFniHjkNy7yg==} + peerDependencies: + prettier: ^3.0.0 + '@radix-ui/number@1.1.1': resolution: {integrity: sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==} @@ -1654,6 +1662,9 @@ packages: '@vitest/utils@4.0.18': resolution: {integrity: sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA==} + '@xml-tools/parser@1.0.11': + resolution: {integrity: sha512-aKqQ077XnR+oQtHJlrAflaZaL7qZsulWc/i/ZEooar5JiWj1eLt0+Wg28cpa+XLney107wXqneC+oG1IZvxkTA==} + '@xmldom/xmldom@0.8.11': resolution: {integrity: sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw==} engines: {node: '>=10.0.0'} @@ -1982,6 +1993,9 @@ packages: character-reference-invalid@2.0.1: resolution: {integrity: sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==} + chevrotain@7.1.1: + resolution: {integrity: sha512-wy3mC1x4ye+O+QkEinVJkPf5u2vsrDIYW9G7ZuwFl6v/Yu0LwUuT2POsb+NUWApebyxfkQq6+yDfRExbnI5rcw==} + chokidar@4.0.3: resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} engines: {node: '>= 14.16.0'} @@ -4114,6 +4128,9 @@ packages: resolution: {integrity: sha512-sZuz1dYW/ZsfG17WSAG7eS85r5a0dDsvg+7BiiYR5o6lKCAtUrEwdmRmaGF6rwVj3LcmAeYkOWKEPlbPzN3Y3A==} engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} + regexp-to-ast@0.5.0: + resolution: {integrity: sha512-tlbJqcMHnPKI9zSrystikWKwHkBqu2a/Sgw01h3zFjvYrMxEDYHzzoMZnUrbIfpTFEsoRnnviOXNCzFiSc54Qw==} + regexp-tree@0.1.27: resolution: {integrity: sha512-iETxpjK6YoRWJG5o6hXLwvjYAoW+FEZn9os0PD/b6AP6xQwsa/Y7lCVgIixBbUPMfhu+i2LtdeAqVTgGlQarfA==} hasBin: true @@ -5612,6 +5629,11 @@ snapshots: '@polka/url@1.0.0-next.29': {} + '@prettier/plugin-xml@3.4.2(prettier@3.8.1)': + dependencies: + '@xml-tools/parser': 1.0.11 + prettier: 3.8.1 + '@radix-ui/number@1.1.1': {} '@radix-ui/primitive@1.1.3': {} @@ -6487,6 +6509,10 @@ snapshots: '@vitest/pretty-format': 4.0.18 tinyrainbow: 3.1.0 + '@xml-tools/parser@1.0.11': + dependencies: + chevrotain: 7.1.1 + '@xmldom/xmldom@0.8.11': {} '@xyflow/react@12.10.1(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': @@ -6830,6 +6856,10 @@ snapshots: character-reference-invalid@2.0.1: {} + chevrotain@7.1.1: + dependencies: + regexp-to-ast: 0.5.0 + chokidar@4.0.3: dependencies: readdirp: 4.1.2 @@ -9446,6 +9476,8 @@ snapshots: '@eslint-community/regexpp': 4.12.2 refa: 0.12.1 + regexp-to-ast@0.5.0: {} + regexp-tree@0.1.27: {} regexp.prototype.flags@1.5.4: diff --git a/src/main/frontend/app/routes/editor/editor.tsx b/src/main/frontend/app/routes/editor/editor.tsx index 4e8ce460..4c7ea6cd 100644 --- a/src/main/frontend/app/routes/editor/editor.tsx +++ b/src/main/frontend/app/routes/editor/editor.tsx @@ -1,5 +1,7 @@ import RulerCrossPenIcon from '/icons/solar/Ruler Cross Pen.svg?react' import Editor, { type Monaco, type OnMount } from '@monaco-editor/react' +import prettier from 'prettier/standalone' +import prettierPluginXml from '@prettier/plugin-xml' type ITextModel = Monaco['editor']['ITextModel'] type FindMatch = Monaco['editor']['FindMatch'] type IModelDeltaDecoration = Monaco['editor']['IModelDeltaDecoration'] @@ -162,6 +164,14 @@ function isConfigurationFile(fileExtension: string) { return fileExtension === 'xml' } +function prettierFormat(xml: string): Promise { + return prettier.format(xml, { + parser: 'xml', + plugins: [prettierPluginXml], + tabWidth: 2, + }) +} + async function validateFlow(content: string, model: ITextModel): Promise { const flowFragment = extractFlowElements(content) if (!flowFragment) return [] @@ -311,7 +321,6 @@ export default function CodeEditor() { if (isConfigurationFile(fileExtension ?? '')) { saveConfiguration(project.name, configPath, updatedContent) .then(({ xmlContent }) => { - setFileContent(xmlContent) contentCacheRef.current.set(activeTabFilePath, { type: 'xml', content: xmlContent }) finishSaving() if (project.isGitRepository) refreshOpenDiffs(project.name) @@ -384,6 +393,28 @@ export default function CodeEditor() { ) }, []) + const runPrettierReformat = async () => { + const editor = editorReference.current + if (!editor) return + const model = editor.getModel() + if (!model) return + try { + const formattedValue = await prettierFormat(model.getValue()) + if (formattedValue === model.getValue()) return + + const selection = editor.getSelection() + editor.pushUndoStop() + editor.executeEdits( + 'prettier-reformat', + [{ range: model.getFullModelRange(), text: formattedValue, forceMoveMarkers: true }], + selection ? [selection] : undefined, + ) + editor.pushUndoStop() + } catch (error) { + console.error('Failed to reformat XML:', error) + } + } + const runSchemaValidation = useCallback( async (content: string) => { const editor = editorReference.current @@ -431,7 +462,6 @@ export default function CodeEditor() { xsdFeatures.addCompletion() xsdFeatures.addGenerateAction() - xsdFeatures.addReformatAction() fetchFrankConfigXsd() .then((xsdContent) => { @@ -481,6 +511,19 @@ export default function CodeEditor() { } }, }) + + editor.addAction({ + id: 'reformat-xml-prettier', + label: 'Reformat', + contextMenuGroupId: 'navigation', + contextMenuOrder: 3, + keybindings: [ + monacoReference.current.KeyMod.Alt | + monacoReference.current.KeyMod.Shift | + monacoReference.current.KeyCode.KeyF, + ], + run: runPrettierReformat, + }) } useEffect(() => { @@ -708,7 +751,7 @@ export default function CodeEditor() { applyFlowHighlighter() // Real-time highlight updates } }} - options={{ automaticLayout: true, quickSuggestions: false }} + options={{ automaticLayout: true, quickSuggestions: false, tabSize: 2, insertSpaces: true, detectIndentation: false }} /> diff --git a/src/main/frontend/app/routes/studio/canvas/flow.tsx b/src/main/frontend/app/routes/studio/canvas/flow.tsx index 52ceb3b7..5103c3f4 100644 --- a/src/main/frontend/app/routes/studio/canvas/flow.tsx +++ b/src/main/frontend/app/routes/studio/canvas/flow.tsx @@ -173,7 +173,7 @@ function FlowCanvas() { await saveConfiguration(currentProject.name, configurationPath, updatedConfigXml) clearConfigurationCache(currentProject.name, configurationPath) useEditorTabStore.getState().refreshAllTabs() - if (currentProject.isGitRepository) refreshOpenDiffs(currentProject.name) + if (currentProject.isGitRepository) await refreshOpenDiffs(currentProject.name) setSaveStatus('saved') if (savedTimerRef.current) clearTimeout(savedTimerRef.current) diff --git a/src/main/frontend/app/stores/shortcut-store.ts b/src/main/frontend/app/stores/shortcut-store.ts index a1a0c872..e85b15b0 100644 --- a/src/main/frontend/app/stores/shortcut-store.ts +++ b/src/main/frontend/app/stores/shortcut-store.ts @@ -111,6 +111,14 @@ export const ALL_SHORTCUTS: Omit[] = [ modifiers: { cmdOrCtrl: true, shift: true }, displayOnly: true, }, + { + id: 'editor.reformat', + label: 'Reformat XML', + scope: 'editor', + key: 'f', + modifiers: { alt: true, shift: true }, + displayOnly: true, + }, { id: 'editor.search', label: 'Find', scope: 'editor', key: 'f', modifiers: { cmdOrCtrl: true }, displayOnly: true }, { id: 'editor.replace', diff --git a/src/main/frontend/package.json b/src/main/frontend/package.json index e4f2d532..65b9eefd 100644 --- a/src/main/frontend/package.json +++ b/src/main/frontend/package.json @@ -61,6 +61,7 @@ "eslint-plugin-sonarjs": "^3.0.7", "eslint-plugin-unicorn": "^62.0.0", "jsdom": "^27.4.0", + "@prettier/plugin-xml": "^3.4.1", "prettier": "^3.8.1", "prettier-plugin-tailwindcss": "^0.6.14", "react-router-devtools": "^1.1.10", diff --git a/src/main/java/org/frankframework/flow/adapter/AdapterService.java b/src/main/java/org/frankframework/flow/adapter/AdapterService.java index a91f2536..d9742461 100644 --- a/src/main/java/org/frankframework/flow/adapter/AdapterService.java +++ b/src/main/java/org/frankframework/flow/adapter/AdapterService.java @@ -84,7 +84,6 @@ public boolean updateAdapter(Path configurationFile, String adapterName, String String updatedXml = XmlConfigurationUtils.convertNodeToString(configDoc); Files.writeString(absConfigFile, updatedXml, StandardCharsets.UTF_8, StandardOpenOption.TRUNCATE_EXISTING); return true; - } catch (AdapterNotFoundException e) { throw e; } catch (Exception e) { diff --git a/src/main/java/org/frankframework/flow/configuration/ConfigurationController.java b/src/main/java/org/frankframework/flow/configuration/ConfigurationController.java index 15c038de..40f0913c 100644 --- a/src/main/java/org/frankframework/flow/configuration/ConfigurationController.java +++ b/src/main/java/org/frankframework/flow/configuration/ConfigurationController.java @@ -52,7 +52,7 @@ public ResponseEntity updateConfiguration( public ResponseEntity addConfiguration( @PathVariable String projectName, @RequestParam String name - ) throws ApiException, IOException { + ) throws ApiException, IOException, TransformerException, ParserConfigurationException, SAXException { String content = configurationService.addConfiguration(projectName, name); XmlDTO xmlDTO = new XmlDTO(content); return ResponseEntity.ok(xmlDTO); diff --git a/src/main/java/org/frankframework/flow/configuration/ConfigurationService.java b/src/main/java/org/frankframework/flow/configuration/ConfigurationService.java index e3d204b2..716ad2d0 100644 --- a/src/main/java/org/frankframework/flow/configuration/ConfigurationService.java +++ b/src/main/java/org/frankframework/flow/configuration/ConfigurationService.java @@ -49,14 +49,18 @@ public String updateConfiguration(String projectName, String filepath, String co throw new ApiException("Invalid file path: " + filepath, HttpStatus.NOT_FOUND); } - Document updatedDocument = XmlConfigurationUtils.insertFlowNamespace(content); - String updatedContent = XmlConfigurationUtils.convertNodeToString(updatedDocument); - fileSystemStorage.writeFile(absolutePath.toString(), updatedContent); - return updatedContent; + Document document = XmlConfigurationUtils.insertFlowNamespace(content); + if (document == null) { + throw new ApiException("Configuration content must not be blank", HttpStatus.BAD_REQUEST); + } + + String formatted = XmlConfigurationUtils.convertNodeToString(document); + fileSystemStorage.writeFile(absolutePath.toString(), formatted); + return formatted; } - public String addConfiguration(String projectName, String configurationName) throws IOException, ApiException { + public String addConfiguration(String projectName, String configurationName) throws IOException, ApiException, TransformerException, ParserConfigurationException, SAXException { Project project = projectService.getProject(projectName); Path absProjectPath = fileSystemStorage.toAbsolutePath(project.getRootPath()); Path configDir = absProjectPath.resolve(CONFIGURATIONS_DIR).normalize(); @@ -71,8 +75,11 @@ public String addConfiguration(String projectName, String configurationName) thr } String defaultXml = loadDefaultConfigurationXml(); - fileSystemStorage.writeFile(filePath.toString(), defaultXml); - return defaultXml; + Document updatedDocument = XmlConfigurationUtils.insertFlowNamespace(defaultXml); + String updatedContent = XmlConfigurationUtils.convertNodeToString(updatedDocument); + fileSystemStorage.writeFile(filePath.toString(), updatedContent); + + return updatedContent; } private String loadDefaultConfigurationXml() throws IOException { diff --git a/src/test/java/org/frankframework/flow/configuration/ConfigurationServiceTest.java b/src/test/java/org/frankframework/flow/configuration/ConfigurationServiceTest.java index 2e4ef0f6..9fd40cfd 100644 --- a/src/test/java/org/frankframework/flow/configuration/ConfigurationServiceTest.java +++ b/src/test/java/org/frankframework/flow/configuration/ConfigurationServiceTest.java @@ -2,6 +2,7 @@ import static org.junit.jupiter.api.Assertions.*; import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.*; import java.io.IOException; @@ -111,8 +112,9 @@ void updateConfiguration_Success() throws Exception { configurationService.updateConfiguration("test", file.toString(), ""); - assertEquals("\n", Files.readString(file, StandardCharsets.UTF_8)); - verify(fileSystemStorage).writeFile(file.toString(), "\n"); + String result = Files.readString(file, StandardCharsets.UTF_8).trim(); + assertEquals("", result); + verify(fileSystemStorage).writeFile(eq(file.toString()), anyString()); } @Test