Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions src/main/frontend/app/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,10 @@ body {
}
}

.monaco-editor .highlight-line {
@apply bg-yellow-200/30 border-l-4 border-yellow-400 transition-colors;
}

:root {
/* Allotment Styling */
--focus-border: var(--color-brand);
Expand Down
3 changes: 2 additions & 1 deletion src/main/frontend/app/routes/builder/builder-structure.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ export default function BuilderStructure() {
setIsLoading: state.setIsLoading,
})),
)
const project = useProjectStore.getState().project
const [searchTerm, setSearchTerm] = useState('')
const tree = useRef<TreeRef>(null)
const dataProviderReference = useRef(new BuilderFilesDataProvider([]))
Expand All @@ -67,7 +68,7 @@ export default function BuilderStructure() {
try {
const loaded: ConfigWithAdapters[] = await Promise.all(
configurationNames.map(async (configName) => {
const adapterNames = await getAdapterNamesFromConfiguration(configName)
const adapterNames = await getAdapterNamesFromConfiguration(project!.name, configName)
return { configName, adapterNames }
}),
)
Expand Down
8 changes: 7 additions & 1 deletion src/main/frontend/app/routes/builder/canvas/flow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import { convertAdapterXmlToJson, getAdapterFromConfiguration } from '~/routes/b
import { exportFlowToXml } from '~/routes/builder/flow-to-xml-parser'
import useNodeContextStore from '~/stores/node-context-store'
import CreateNodeModal from '~/components/flow/create-node-modal'
import {useProjectStore} from "~/stores/project-store";

export type FlowNode = FrankNode | ExitNode | StickyNote | GroupNode | Node

Expand Down Expand Up @@ -68,6 +69,7 @@ function FlowCanvas({ showNodeContextMenu }: Readonly<{ showNodeContextMenu: (b:
const { nodes, edges, viewport, onNodesChange, onEdgesChange, onConnect, onReconnect } = useFlowStore(
useShallow(selector),
)
const project = useProjectStore.getState().project

const sourceInfoReference = useRef<{
nodeId: string | null
Expand Down Expand Up @@ -430,7 +432,11 @@ function FlowCanvas({ showNodeContextMenu }: Readonly<{ showNodeContextMenu: (b:
if (tab.flowJson && Object.keys(tab.flowJson).length > 0) {
restoreFlowFromTab(tab.value)
} else if (tab.configurationName && tab.value) {
const adapter = await getAdapterFromConfiguration(tab.configurationName, tab.value)
const adapter = await getAdapterFromConfiguration(
project!.name,
tab.configurationName,
tab.value,
)
if (!adapter) return
const adapterJson = await convertAdapterXmlToJson(adapter)
flowStore.setEdges(adapterJson.edges)
Expand Down
21 changes: 11 additions & 10 deletions src/main/frontend/app/routes/builder/xml-to-json-parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,21 +8,22 @@ interface IdCounter {
current: number
}

export async function getXmlString(filename: string): Promise<string> {
export async function getXmlString(projectName: string, filename: string): Promise<string> {
try {
const response = await fetch(`/configurations/${filename}`)
const response = await fetch(`/projects/${projectName}/${filename}`);
if (!response.ok) {
throw new Error(`HTTP error! Status: ${response.status}`)
throw new Error(`HTTP error! Status: ${response.status}`);
}
const data = await response.json()
return data.xmlContent

const data = await response.json();
return data.xmlContent;
} catch (error) {
throw new Error(`Failed to fetch XML file: ${error}`)
throw new Error(`Failed to fetch XML file for ${projectName}/${filename}: ${error}`);
}
}

export async function getAdapterNamesFromConfiguration(filename: string): Promise<string[]> {
const xmlString = await getXmlString(filename)
export async function getAdapterNamesFromConfiguration(projectName: string, filename: string): Promise<string[]> {
const xmlString = await getXmlString(projectName, filename)

return new Promise((resolve, reject) => {
const adapterNames: string[] = []
Expand Down Expand Up @@ -50,8 +51,8 @@ export async function getAdapterNamesFromConfiguration(filename: string): Promis
})
}

export async function getAdapterFromConfiguration(filename: string, adapterName: string): Promise<Element | null> {
const xmlString = await getXmlString(filename)
export async function getAdapterFromConfiguration(projectname: string, filename: string, adapterName: string): Promise<Element | null> {
const xmlString = await getXmlString(projectname, filename)
const parser = new DOMParser()
const xmlDoc = parser.parseFromString(xmlString, 'text/xml')

Expand Down
3 changes: 2 additions & 1 deletion src/main/frontend/app/routes/editor/editor-files.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ export default function EditorFiles() {
setIsLoading: state.setIsLoading,
})),
)
const project = useProjectStore.getState().project
const [searchTerm, setSearchTerm] = useState('')
const tree = useRef<TreeRef>(null)
const dataProviderReference = useRef(new BuilderFilesDataProvider([]))
Expand All @@ -68,7 +69,7 @@ export default function EditorFiles() {
try {
const loaded: ConfigWithAdapters[] = await Promise.all(
configurationNames.map(async (configName) => {
const adapterNames = await getAdapterNamesFromConfiguration(configName)
const adapterNames = await getAdapterNamesFromConfiguration(project!.name, configName)
return { configName, adapterNames }
}),
)
Expand Down
224 changes: 217 additions & 7 deletions src/main/frontend/app/routes/editor/editor.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import Tabs, {type TabsList} from '~/components/tabs/tabs'
import Tabs, { type TabsList } from '~/components/tabs/tabs'
import Editor from '@monaco-editor/react'
import EditorFiles from '~/routes/editor/editor-files'
import SidebarHeader from '~/components/sidebars-layout/sidebar-header'
Expand All @@ -7,12 +7,27 @@ import { SidebarSide } from '~/components/sidebars-layout/sidebar-layout-store'
import SidebarContentClose from '~/components/sidebars-layout/sidebar-content-close'
import useTabStore from '~/stores/tab-store'
import { useTheme } from '~/hooks/use-theme'
import {useEffect, useState} from "react";
import { useEffect, useRef, useState } from 'react'
import { getXmlString } from '~/routes/builder/xml-to-json-parser'
import variables from '../../../environment/environment'
import { useFFDoc } from '@frankframework/ff-doc/react'
import { useProjectStore } from '~/stores/project-store'

export default function CodeEditor() {
const theme = useTheme()
const FRANK_DOC_URL = variables.frankDocJsonUrl
const { elements } = useFFDoc(FRANK_DOC_URL)
const project = useProjectStore.getState().project
const [tabs, setTabs] = useState<TabsList>(useTabStore.getState().tabs as TabsList)
const [activeTab, setActiveTab] = useState<string | undefined>(useTabStore.getState().activeTab)
const [xmlContent, setXmlContent] = useState<string>('')
const editorReference = useRef<any>(null)
const decorationIdsReference = useRef<string[]>([])
const [isSaving, setIsSaving] = useState(false)

const handleEditorMount = (editor: any, monaco: any) => {
editorReference.current = editor
}

useEffect(() => {
const unsubTabs = useTabStore.subscribe((state) => {
Expand All @@ -30,6 +45,149 @@ export default function CodeEditor() {
}
}, [])

useEffect(() => {
async function fetchXml() {
try {
const configName = useTabStore.getState().getTab(activeTab)?.configurationName
if (!configName) return
const xmlString = await getXmlString(project!.name, configName)
setXmlContent(xmlString)
} catch (error) {
console.error('Failed to load XML:', error)
}
}

fetchXml()
}, [activeTab])

useEffect(() => {
// Highlights the line of the adapter which is selected
if (!xmlContent || !activeTab || !editorReference.current) return

const editor = editorReference.current
const lines = xmlContent.split('\n')
const matchIndex = lines.findIndex((line) => line.includes('<Adapter') && line.includes(activeTab))
if (matchIndex === -1) return

const lineNumber = matchIndex + 1

// reveal and position
setTimeout(() => {
editor.revealLineNearTop(lineNumber)
editor.setPosition({ lineNumber, column: 1 })
editor.focus()

// apply highlight decoration
decorationIdsReference.current = editor.deltaDecorations(decorationIdsReference.current, [
{
range: { startLineNumber: lineNumber, startColumn: 1, endLineNumber: lineNumber, endColumn: 1 },
options: {
isWholeLine: true,
className: 'highlight-line',
},
},
])

// remove highlight after 2s
const t = setTimeout(() => {
decorationIdsReference.current = editor.deltaDecorations(decorationIdsReference.current, [])
}, 2000)

// optional cleanup if component unmounts before timeout
return () => clearTimeout(t)
}, 50)
}, [xmlContent, activeTab])

useEffect(() => {
// Handles all the suggestions
if (!editorReference.current) return
const monacoInstance = (globalThis as any).monaco
if (!monacoInstance) return

// Keep latest elements in a ref so provider callbacks always see current data
const elementsReference = { current: elements }

// Element suggestions
const elementProvider = monacoInstance.languages.registerCompletionItemProvider('xml', {
triggerCharacters: ['<'],
provideCompletionItems: () => {
return {
suggestions: Object.values(elementsReference.current).map((element: any) => {
// Mandatory attributes
const mandatoryAttributes = Object.entries(element.attributes || {})
.filter(([_, attribute]) => attribute.mandatory)
.map(([name]) => `${name}="\${${name}}"`)

// Snippet for tag + mandatory attributes
// eslint-disable-next-line sonarjs/no-nested-template-literals
const insertText = `${element.name}${mandatoryAttributes.length > 0 ? ` ${mandatoryAttributes.join(' ')}` : ''}>$0</${element.name}`

return {
label: element.name,
kind: monacoInstance.languages.CompletionItemKind.Class,
insertText,
insertTextRules: monacoInstance.languages.CompletionItemInsertTextRule.InsertAsSnippet,
documentation: element.description || '',
}
}),
}
},
})

// Attribute suggestions
const attributeProvider = monacoInstance.languages.registerCompletionItemProvider('xml', {
triggerCharacters: [' '],
provideCompletionItems: (model, position) => {
const line = model.getLineContent(position.lineNumber)
const textBeforeCursor = line.slice(0, position.column - 1)

// Don't show suggestions if cursor is inside quotes
const quotesBefore = (textBeforeCursor.match(/"/g) || []).length
if (quotesBefore % 2 === 1) {
// Odd number of quotes -> cursor is inside an attribute value
return { suggestions: [] }
}

const tagMatch = line.slice(0, position.column - 1).match(/<(\w+)/)
if (!tagMatch) return { suggestions: [] }

const tagName = tagMatch[1]
const element = (elementsReference.current as any)[tagName]
if (!element || !element.attributes) return { suggestions: [] }

const attributeSuggestions = Object.entries(element.attributes).flatMap(
([attributeName, attributeValue]: [string, any]) => {
// Suggest enum values if defined
const enumValues = attributeValue?.enum ? Object.keys(attributeValue.enum) : []

return enumValues.length > 0
? enumValues.map((value, index) => ({
label: `${attributeName}="${value}"`,
kind: monacoInstance.languages.CompletionItemKind.Enum,
insertText: `${attributeName}="${value}"`,
documentation: attributeValue?.enum[value]?.description || '',
}))
: {
label: attributeName,
kind: monacoInstance.languages.CompletionItemKind.Property,
insertText: `${attributeName}="\${1}"`,
insertTextRules: monacoInstance.languages.CompletionItemInsertTextRule.InsertAsSnippet,
documentation: attributeValue?.description || '',
}
},
)

return { suggestions: attributeSuggestions }
},
})

// Cleanup
return () => {
elementProvider.dispose()
attributeProvider.dispose()
}
}, [editorReference.current, elements])

const handleSelectTab = (key: string) => {
useTabStore.getState().setActiveTab(key)
}
Expand All @@ -49,6 +207,32 @@ export default function CodeEditor() {
}
}

const handleSave = async () => {
if (!project || !activeTab) return
const configName = useTabStore.getState().getTab(activeTab)?.configurationName
if (!configName) return

const editor = editorReference.current
const updatedXml = editor?.getValue?.()
if (!updatedXml) return

setIsSaving(true)

try {
const response = await fetch(`/projects/${project.name}/${configName}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ xmlContent: updatedXml }),
})

if (!response.ok) throw new Error(`HTTP error! Status: ${response.status}`)
} catch (error) {
console.error('Failed to save XML:', error)
} finally {
setIsSaving(false)
}
}

return (
<SidebarLayout name="editor" windowResizeOnChange={true}>
<>
Expand All @@ -61,15 +245,41 @@ export default function CodeEditor() {
<div className="grow overflow-x-auto">
<Tabs tabs={tabs} selectedTab={activeTab} onSelectTab={handleSelectTab} onCloseTab={handleCloseTab} />
</div>
<SidebarContentClose side={SidebarSide.RIGHT} />
</div>
<div className="border-b-border bg-background flex h-12 items-center border-b p-4">Path: {activeTab}</div>
<div className="h-full">
<Editor language="xml" theme={`vs-${theme}`} />
</div>
{activeTab ? (
<>
<div className="border-b-border bg-background flex h-12 items-center border-b p-4">Path: {activeTab}</div>
<div className="h-full">
<Editor
language="xml"
theme={`vs-${theme}`}
value={xmlContent}
onMount={handleEditorMount}
options={{ automaticLayout: true }}
/>
</div>
</>
) : (
<div className="text-muted-foreground flex h-full flex-col items-center justify-center p-8 text-center">
<div className="border-border bg-background/40 max-w-md rounded-2xl border border-dashed p-10 shadow-inner backdrop-blur-sm">
<h2 className="mb-2 text-xl font-semibold">No file selected</h2>
<p className="text-sm">Select an adapter from the file structure on the left to start editing.</p>
</div>
</div>
)}
</>
<>
<SidebarHeader side={SidebarSide.RIGHT} title="Preview" />
<div className="h-full">Preview</div>
<div className="flex w-full items-center justify-center">
<button
onClick={handleSave}
disabled={isSaving || !activeTab}
className="border-border bg-background hover:bg-foreground-active my-2 rounded border px-3 py-1 disabled:opacity-50"
>
{isSaving ? 'Saving...' : 'Save XML'}
</button>
</div>
</>
</SidebarLayout>
)
Expand Down
Loading