diff --git a/src/management-system-v2/components/modeler.tsx b/src/management-system-v2/components/modeler.tsx index 6072e48ba..51e1a6517 100644 --- a/src/management-system-v2/components/modeler.tsx +++ b/src/management-system-v2/components/modeler.tsx @@ -138,12 +138,14 @@ const Modeler: FC = ({ minimized, ...props }) => { const handleXmlEditorSave = async (bpmn: string) => { if (modeler.current) { - modeler.current.importXML(bpmn).then(() => { + await modeler.current.importXML(bpmn).then(() => { (modeler.current!.get('canvas') as any).zoom('fit-viewport', 'auto'); }); + // if the bpmn contains unexpected content (text content for an element where the model does not define text) the modeler will remove it automatically => make sure the stored bpmn is the same as the one in the modeler + const { xml: cleanedBpmn } = await modeler.current.saveXML({ format: true }); await updateProcessMutation({ params: { path: { definitionId: processId as string } }, - body: { bpmn }, + body: { bpmn: cleanedBpmn }, }); } }; @@ -154,12 +156,14 @@ const Modeler: FC = ({ minimized, ...props }) => { <> {selectedVersionId && } - + {!!xmlEditorBpmn && ( + + )} )}
diff --git a/src/management-system-v2/components/xml-editor.tsx b/src/management-system-v2/components/xml-editor.tsx index 0188b90e5..eee600f69 100644 --- a/src/management-system-v2/components/xml-editor.tsx +++ b/src/management-system-v2/components/xml-editor.tsx @@ -1,47 +1,236 @@ 'use client'; -import React, { FC, useRef } from 'react'; +import React, { FC, useRef, useState } from 'react'; -import { Modal } from 'antd'; +import { useParams, useSearchParams } from 'next/navigation'; +import { Modal, Button, Tooltip, Flex, Popconfirm, Space } from 'antd'; +import { SearchOutlined, DownloadOutlined, CopyOutlined } from '@ant-design/icons'; import { Typography } from 'antd'; const { Title } = Typography; import Editor, { Monaco } from '@monaco-editor/react'; import * as monaco from 'monaco-editor'; +import { moddle } from '@proceed/bpmn-helper'; + +import { debounce } from '@/lib/utils'; +import { useGetAsset } from '@/lib/fetch-data'; +import { downloadFile } from '@/lib/process-export/util'; + type XmlEditorProps = { bpmn?: string; canSave: boolean; onClose: () => void; - onSaveXml: (bpmn: string) => void; + onSaveXml: (bpmn: string) => Promise; }; +/** + * Checks for syntax errors in the bpmn as well as for moddle warnings + * + * @param bpmn + * @returns found syntax errors or warnings + */ +async function checkBpmn(bpmn: string) { + // check the bpmn (xml) for syntax errors using the domparser + const domParser = new DOMParser(); + var dom = domParser.parseFromString(bpmn, 'text/xml'); + const parserErrors = dom.getElementsByTagName('parsererror'); + if (parserErrors.length) { + const match = parserErrors[0].textContent!.match( + /This page contains the following errors:error on line (\d+) at column (\d+): (.+)\nBelow is a rendering of the page up to the first error./, + ); + + if (match) { + // convert the positional information into a format that can be passed to the monaco editor + let [_, lineString, columnString, message] = match; + const line = parseInt(lineString); + const column = parseInt(columnString); + return { + error: { + startLineNumber: line, + endLineNumber: line, + startColumn: column, + endColumn: column + 1, + message, + }, + }; + } + } + + // check for bpmn related mistakes (nonconformity with the underlying model [e.g. unknown elements or attributes]) + const { warnings } = await moddle.fromXML(bpmn); + return { warnings }; +} + const XmlEditor: FC = ({ bpmn, canSave, onClose, onSaveXml }) => { const editorRef = useRef(null); + const monacoRef = useRef(null); + const [saveState, setSaveState] = useState<'error' | 'warning' | 'none'>('none'); - const handleEditorMount = (editor: monaco.editor.IStandaloneCodeEditor) => { + const handleEditorMount = (editor: monaco.editor.IStandaloneCodeEditor, monaco: Monaco) => { editorRef.current = editor; + monacoRef.current = monaco; }; const handleSave = async () => { if (editorRef.current) { const newBpmn = editorRef.current.getValue(); - onSaveXml(newBpmn); + + await onSaveXml(newBpmn); + onClose(); + } + }; + + async function validateProcess() { + if (editorRef.current && monacoRef.current) { + // reset error markings in the editor + monacoRef.current.editor.setModelMarkers(editorRef.current.getModel()!, 'owner', []); + setSaveState('none'); + const bpmn = editorRef.current.getValue(); + + const { error, warnings } = await checkBpmn(bpmn); + + if (error) { + setSaveState('error'); + // add new error markings + monacoRef.current.editor.setModelMarkers(editorRef.current.getModel()!, 'owner', [ + { ...error, severity: monacoRef.current.MarkerSeverity.Error }, + ]); + + return false; + } else if (warnings && warnings.length) { + setSaveState('warning'); + } } + + return true; + } + + // run the validation when something changes and add code highlighting (with debounce) + const handleChange = debounce(() => validateProcess(), 100); + + const handleValidationAndSave = async () => { + if (editorRef.current && monacoRef.current) { + const newBpmn = editorRef.current.getValue(); + + const { error } = await checkBpmn(newBpmn); + + if (!error) { + handleSave(); + } + } + }; + + const handleCopyToClipboard = () => { + if (editorRef.current) { + navigator.clipboard.writeText(editorRef.current.getValue()); + } + }; + + const { processId } = useParams(); + + const { data: process, isSuccess } = useGetAsset('/process/{definitionId}', { + params: { path: { definitionId: processId as string } }, + }); + + const selectedVersionId = parseInt(useSearchParams().get('version') ?? '-1'); + + const handleDownload = async () => { + if (editorRef.current && isSuccess) { + let filename = process!.definitionName || process!.definitionId || 'process'; + + if (selectedVersionId > -1) { + const versionInfo = process?.versions.find(({ version }) => version == selectedVersionId); + + if (versionInfo) { + filename += `_version_${versionInfo.name || selectedVersionId}`; + } + } + + await downloadFile( + `${filename}.bpmn`, + new Blob([editorRef.current.getValue()], { type: 'application/xml' }), + ); + } + }; + + // display different information for the save button and handle its click differently based on the current state of the editor (error / warnings / no issues) + const saveButton = { + disabled: ( + + + + ), + warning: ( + + There are unrecognized attributes or
elements in the BPMN. Save anyway? + + } + onConfirm={handleValidationAndSave} + okText="Save" + cancelText="Cancel" + > + +
+ ), + normal: ( + + ), }; return ( BPMN XML} - okButtonProps={{ disabled: !canSave }} + title={ + + BPMN XML + + + , + ((!canSave || saveState === 'error') && saveButton['disabled']) || + (saveState === 'warning' && saveButton['warning']) || + saveButton['normal'], + ]} > = ({ bpmn, canSave, onClose, onSaveXml }) => wordWrap: 'on', wrappingStrategy: 'advanced', wrappingIndent: 'same', + readOnly: !canSave, }} onMount={handleEditorMount} + onChange={handleChange} height="85vh" + className="Hide-Scroll-Bar" /> ); diff --git a/src/management-system-v2/lib/utils.ts b/src/management-system-v2/lib/utils.ts index 776c22695..eee29311c 100644 --- a/src/management-system-v2/lib/utils.ts +++ b/src/management-system-v2/lib/utils.ts @@ -16,3 +16,22 @@ export function generateDateString(date?: Date | string, includeTime: boolean = type JSONValue = string | number | boolean | null | JSONObject | JSONArray; type JSONObject = { [key: string]: JSONValue }; type JSONArray = JSONValue[]; + +/** + * Allows to create a function that will only run its logic if it has not been called for a specified amount of time + * + * example use-case: you don't want to check some text entered by a user on every keystroke but only when the user has stopped entering new text + * + * found here: https://www.freecodecamp.org/news/javascript-debounce-example/ + * + * @param func the function to call after the debounce timeout has elapsed + * @param timeout the time that needs to elapse without a function call before the logic is executed + * @returns the function to call for the debounc behaviour + */ +export function debounce(func: Function, timeout = 1000) { + let timer: ReturnType | undefined; + return (...args: any[]) => { + clearTimeout(timer); + timer = setTimeout(() => func(...args), timeout); + }; +}