Skip to content

Commit

Permalink
Merge pull request #156 from PROCEED-Labs/ms2/xml-editor-improvements
Browse files Browse the repository at this point in the history
Ms2/xml editor improvements
  • Loading branch information
LucasMGo authored Nov 21, 2023
2 parents d0583a3 + ebaf7c7 commit 8ae762f
Show file tree
Hide file tree
Showing 3 changed files with 233 additions and 18 deletions.
20 changes: 12 additions & 8 deletions src/management-system-v2/components/modeler.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -138,12 +138,14 @@ const Modeler: FC<ModelerProps> = ({ 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 },
});
}
};
Expand All @@ -154,12 +156,14 @@ const Modeler: FC<ModelerProps> = ({ minimized, ...props }) => {
<>
<ModelerToolbar onOpenXmlEditor={handleOpenXmlEditor} />
{selectedVersionId && <VersionToolbar />}
<XmlEditor
bpmn={xmlEditorBpmn}
canSave={!selectedVersionId}
onClose={handleCloseXmlEditor}
onSaveXml={handleXmlEditorSave}
/>
{!!xmlEditorBpmn && (
<XmlEditor
bpmn={xmlEditorBpmn}
canSave={!selectedVersionId}
onClose={handleCloseXmlEditor}
onSaveXml={handleXmlEditorSave}
/>
)}
</>
)}
<div className="modeler" style={{ height: '100%' }} {...props} ref={canvas} />
Expand Down
212 changes: 202 additions & 10 deletions src/management-system-v2/components/xml-editor.tsx
Original file line number Diff line number Diff line change
@@ -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<void>;
};

/**
* 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<XmlEditorProps> = ({ bpmn, canSave, onClose, onSaveXml }) => {
const editorRef = useRef<null | monaco.editor.IStandaloneCodeEditor>(null);
const monacoRef = useRef<null | Monaco>(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: (
<Tooltip
key="tooltip-save-button"
placement="top"
title={
!canSave
? 'Already versioned bpmn cannot be changed!'
: 'Fix the syntax errors in the bpmn before saving!'
}
>
<Button key="disabled-save-button" type="primary" disabled>
Ok
</Button>
</Tooltip>
),
warning: (
<Popconfirm
key="warning-save-button"
title="Warning"
description={
<span>
There are unrecognized attributes or <br /> elements in the BPMN. Save anyway?
</span>
}
onConfirm={handleValidationAndSave}
okText="Save"
cancelText="Cancel"
>
<Button type="primary">Ok</Button>
</Popconfirm>
),
normal: (
<Button key="save-button" type="primary" onClick={handleValidationAndSave}>
Ok
</Button>
),
};

return (
<Modal
open={!!bpmn}
onOk={handleSave}
onOk={handleValidationAndSave}
onCancel={onClose}
centered
width="85vw"
cancelText="Close"
okText="Save"
title={<Title level={3}>BPMN XML</Title>}
okButtonProps={{ disabled: !canSave }}
title={
<Flex justify="space-between">
<Title level={3}>BPMN XML</Title>
<Space.Compact>
<Tooltip title="Download">
<Button icon={<DownloadOutlined />} onClick={handleDownload} />
</Tooltip>
<Tooltip title="Copy to Clipboard">
<Button icon={<CopyOutlined />} onClick={handleCopyToClipboard} />
</Tooltip>
<Tooltip title="Search">
<Button
icon={<SearchOutlined />}
onClick={() => {
if (editorRef.current) editorRef.current.getAction('actions.find')?.run();
}}
/>
</Tooltip>
</Space.Compact>
</Flex>
}
closeIcon={false}
footer={[
<Button key="close-button" onClick={onClose}>
Cancel
</Button>,
((!canSave || saveState === 'error') && saveButton['disabled']) ||
(saveState === 'warning' && saveButton['warning']) ||
saveButton['normal'],
]}
>
<Editor
defaultLanguage="xml"
Expand All @@ -50,9 +239,12 @@ const XmlEditor: FC<XmlEditorProps> = ({ bpmn, canSave, onClose, onSaveXml }) =>
wordWrap: 'on',
wrappingStrategy: 'advanced',
wrappingIndent: 'same',
readOnly: !canSave,
}}
onMount={handleEditorMount}
onChange={handleChange}
height="85vh"
className="Hide-Scroll-Bar"
/>
</Modal>
);
Expand Down
19 changes: 19 additions & 0 deletions src/management-system-v2/lib/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof setTimeout> | undefined;
return (...args: any[]) => {
clearTimeout(timer);
timer = setTimeout(() => func(...args), timeout);
};
}

0 comments on commit 8ae762f

Please sign in to comment.