Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 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)
Comment thread
Daan0709 marked this conversation as resolved.
Outdated
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,
Comment thread
Daan0709 marked this conversation as resolved.
Outdated
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
Comment thread
Daan0709 marked this conversation as resolved.
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) => {
Comment thread
Daan0709 marked this conversation as resolved.
Outdated
editorReference.current = editor
}

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

useEffect(() => {
async function fetchXml() {
Comment thread
Daan0709 marked this conversation as resolved.
try {
const configName = useTabStore.getState().getTab(activeTab)?.configurationName
if (!configName) return
const xmlString = await getXmlString(project!.name, configName)
Comment thread
Daan0709 marked this conversation as resolved.
Outdated
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(() => {
Comment thread
Daan0709 marked this conversation as resolved.
Outdated
decorationIdsReference.current = editor.deltaDecorations(decorationIdsReference.current, [])
}, 2000)

// optional cleanup if component unmounts before timeout
return () => clearTimeout(t)
}, 50)
Comment thread
Daan0709 marked this conversation as resolved.
Outdated
}, [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 }
Comment thread
Daan0709 marked this conversation as resolved.
Outdated

// 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}`
Comment thread
Daan0709 marked this conversation as resolved.
Outdated

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+)/)
Comment thread
Daan0709 marked this conversation as resolved.
Outdated
if (!tagMatch) return { suggestions: [] }

const tagName = tagMatch[1]
const element = (elementsReference.current as any)[tagName]
Comment thread
Daan0709 marked this conversation as resolved.
Outdated
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
Loading