import { useContext, useEffect, useRef, useState } from 'react'; import { useNavigate, useParams, useSearchParams } from 'react-router-dom'; import { Button, Modal, Stack } from 'react-bootstrap'; import Container from 'react-bootstrap/Container'; import Row from 'react-bootstrap/Row'; import Col from 'react-bootstrap/Col'; import Editor from '@monaco-editor/react'; import ReactDiagramEditor from '../components/ReactDiagramEditor'; import ProcessBreadcrumb from '../components/ProcessBreadcrumb'; import HttpService from '../services/HttpService'; import ErrorContext from '../contexts/ErrorContext'; import { makeid } from '../helpers'; import { ProcessModel } from '../interfaces'; export default function ProcessModelEditDiagram() { const [showFileNameEditor, setShowFileNameEditor] = useState(false); const handleShowFileNameEditor = () => setShowFileNameEditor(true); const [scriptText, setScriptText] = useState(''); const [scriptModeling, setScriptModeling] = useState(null); const [scriptElement, setScriptElement] = useState(null); const [showScriptEditor, setShowScriptEditor] = useState(false); const handleShowScriptEditor = () => setShowScriptEditor(true); const editorRef = useRef(null); const monacoRef = useRef(null); const failingScriptLineClassNamePrefix = 'failingScriptLineError'; function handleEditorDidMount(editor: any, monaco: any) { // here is the editor instance // you can store it in `useRef` for further usage editorRef.current = editor; monacoRef.current = monaco; } interface ScriptUnitTest { id: string; inputJson: any; expectedOutputJson: any; } interface ScriptUnitTestResult { result: boolean; context: object; error: string; line_number: number; offset: number; } const [currentScriptUnitTest, setCurrentScriptUnitTest] = useState(null); const [currentScriptUnitTestIndex, setCurrentScriptUnitTestIndex] = useState(-1); const [scriptUnitTestResult, setScriptUnitTestResult] = useState(null); const params = useParams(); const navigate = useNavigate(); const [searchParams] = useSearchParams(); const setErrorMessage = (useContext as any)(ErrorContext)[1]; const [processModelFile, setProcessModelFile] = useState(null); const [newFileName, setNewFileName] = useState(''); const [bpmnXmlForDiagramRendering, setBpmnXmlForDiagramRendering] = useState(null); const [processModel, setProcessModel] = useState(null); const processModelPath = `process-models/${params.process_group_id}/${params.process_model_id}`; useEffect(() => { const processResult = (result: ProcessModel) => { setProcessModel(result); }; HttpService.makeCallToBackend({ path: `/${processModelPath}`, successCallback: processResult, }); }, [processModelPath]); useEffect(() => { const processResult = (result: any) => { setProcessModelFile(result); setBpmnXmlForDiagramRendering(result.file_contents); }; if (params.file_name) { HttpService.makeCallToBackend({ path: `/${processModelPath}/files/${params.file_name}`, successCallback: processResult, }); } }, [processModelPath, params]); const handleFileNameCancel = () => { setShowFileNameEditor(false); setNewFileName(''); }; const navigateToProcessModelFile = (_result: any) => { if (!params.file_name) { const fileNameWithExtension = `${newFileName}.${searchParams.get( 'file_type' )}`; navigate( `/admin/process-models/${params.process_group_id}/${params.process_model_id}/files/${fileNameWithExtension}` ); } }; const saveDiagram = (bpmnXML: any, fileName = params.file_name) => { setErrorMessage(null); setBpmnXmlForDiagramRendering(bpmnXML); let url = `/process-models/${params.process_group_id}/${params.process_model_id}/files`; let httpMethod = 'PUT'; let fileNameWithExtension = fileName; if (newFileName) { fileNameWithExtension = `${newFileName}.${searchParams.get('file_type')}`; httpMethod = 'POST'; } else { url += `/${fileNameWithExtension}`; } if (!fileNameWithExtension) { handleShowFileNameEditor(); return; } const bpmnFile = new File([bpmnXML], fileNameWithExtension); const formData = new FormData(); formData.append('file', bpmnFile); formData.append('fileName', bpmnFile.name); HttpService.makeCallToBackend({ path: url, successCallback: navigateToProcessModelFile, failureCallback: setErrorMessage, httpMethod, postBody: formData, }); // after saving the file, make sure we null out newFileName // so it does not get used over the params setNewFileName(''); }; const onDeleteFile = (fileName = params.file_name) => { const url = `/process-models/${params.process_group_id}/${params.process_model_id}/files/${fileName}`; const httpMethod = 'DELETE'; const navigateToProcessModelShow = (_httpResult: any) => { navigate( `/admin/process-models/${params.process_group_id}/${params.process_model_id}` ); }; HttpService.makeCallToBackend({ path: url, successCallback: navigateToProcessModelShow, httpMethod, }); }; const onSetPrimaryFile = (fileName = params.file_name) => { const url = `/process-models/${params.process_group_id}/${params.process_model_id}`; const httpMethod = 'PUT'; const navigateToProcessModelShow = (_httpResult: any) => { navigate(`/admin${url}`); }; const processModelToPass = { primary_file_name: fileName, }; HttpService.makeCallToBackend({ path: url, successCallback: navigateToProcessModelShow, httpMethod, postBody: processModelToPass, }); }; const handleFileNameSave = (event: any) => { event.preventDefault(); setShowFileNameEditor(false); saveDiagram(bpmnXmlForDiagramRendering); }; const newFileNameBox = () => { const fileExtension = `.${searchParams.get('file_type')}`; return ( Process Model File Name
setNewFileName(e.target.value)} autoFocus /> {fileExtension}
); }; const resetUnitTextResult = () => { setScriptUnitTestResult(null); const styleSheet = document.styleSheets[0]; const ruleList = styleSheet.cssRules; for (let ii = ruleList.length - 1; ii >= 0; ii -= 1) { const regexp = new RegExp( `^.${failingScriptLineClassNamePrefix}_.*::after ` ); if (ruleList[ii].cssText.match(regexp)) { styleSheet.deleteRule(ii); } } }; const makeApiHandler = (event: any) => { return function fireEvent(results: any) { event.eventBus.fire('spiff.service_tasks.returned', { serviceTaskOperators: results, }); }; }; const onServiceTasksRequested = (event: any) => { HttpService.makeCallToBackend({ path: `/service_tasks`, successCallback: makeApiHandler(event), }); }; const getScriptUnitTestElements = (element: any) => { const { extensionElements } = element.businessObject; if (extensionElements && extensionElements.values.length > 0) { const unitTestModdleElements = extensionElements .get('values') .filter(function getInstanceOfType(e: any) { return e.$instanceOf('spiffworkflow:unitTests'); })[0]; if (unitTestModdleElements) { return unitTestModdleElements.unitTests; } } return []; }; const setScriptUnitTestElementWithIndex = ( scriptIndex: number, element: any = scriptElement ) => { const unitTestsModdleElements = getScriptUnitTestElements(element); if (unitTestsModdleElements.length > 0) { setCurrentScriptUnitTest(unitTestsModdleElements[scriptIndex]); setCurrentScriptUnitTestIndex(scriptIndex); } }; const onLaunchScriptEditor = (element: any, modeling: any) => { setScriptText(element.businessObject.script || ''); setScriptModeling(modeling); setScriptElement(element); setScriptUnitTestElementWithIndex(0, element); handleShowScriptEditor(); }; const handleScriptEditorClose = () => { resetUnitTextResult(); setShowScriptEditor(false); }; const handleEditorScriptChange = (value: any) => { setScriptText(value); (scriptModeling as any).updateProperties(scriptElement, { scriptFormat: 'python', script: value, }); }; const handleEditorScriptTestUnitInputChange = (value: any) => { if (currentScriptUnitTest) { currentScriptUnitTest.inputJson.value = value; (scriptModeling as any).updateProperties(scriptElement, {}); } }; const handleEditorScriptTestUnitOutputChange = (value: any) => { if (currentScriptUnitTest) { currentScriptUnitTest.expectedOutputJson.value = value; (scriptModeling as any).updateProperties(scriptElement, {}); } }; const generalEditorOptions = () => { return { glyphMargin: false, folding: false, lineNumbersMinChars: 0, }; }; const setPreviousScriptUnitTest = () => { resetUnitTextResult(); const newScriptIndex = currentScriptUnitTestIndex - 1; if (newScriptIndex >= 0) { setScriptUnitTestElementWithIndex(newScriptIndex); } }; const setNextScriptUnitTest = () => { resetUnitTextResult(); const newScriptIndex = currentScriptUnitTestIndex + 1; const unitTestsModdleElements = getScriptUnitTestElements(scriptElement); if (newScriptIndex < unitTestsModdleElements.length) { setScriptUnitTestElementWithIndex(newScriptIndex); } }; const processScriptUnitTestRunResult = (result: any) => { if ('result' in result) { setScriptUnitTestResult(result); if ( result.line_number && result.error && editorRef.current && monacoRef.current ) { const currentClassName = `${failingScriptLineClassNamePrefix}_${makeid( 7 )}`; // document.documentElement.style.setProperty causes the content property to go away // so add the rule dynamically instead of changing a property variable document.styleSheets[0].addRule( `.${currentClassName}::after`, `content: " # ${result.error.replaceAll('"', '')}"; color: red` ); const lineLength = scriptText.split('\n')[result.line_number - 1].length + 1; const editorRefToUse = editorRef.current as any; editorRefToUse.deltaDecorations( [], [ { // Range(lineStart, column, lineEnd, column) range: new (monacoRef.current as any).Range( result.line_number, lineLength ), options: { afterContentClassName: currentClassName }, }, ] ); } } }; const runCurrentUnitTest = () => { if (currentScriptUnitTest && scriptElement) { resetUnitTextResult(); HttpService.makeCallToBackend({ path: `/process-models/${params.process_group_id}/${params.process_model_id}/script-unit-tests/run`, httpMethod: 'POST', successCallback: processScriptUnitTestRunResult, postBody: { bpmn_task_identifier: (scriptElement as any).id, python_script: scriptText, input_json: JSON.parse(currentScriptUnitTest.inputJson.value), expected_output_json: JSON.parse( currentScriptUnitTest.expectedOutputJson.value ), }, }); } }; const unitTestFailureElement = () => { if ( scriptUnitTestResult && scriptUnitTestResult.result === false && !scriptUnitTestResult.line_number ) { let errorStringElement = null; if (scriptUnitTestResult.error) { errorStringElement = ( Received error when running script:{' '} {JSON.stringify(scriptUnitTestResult.error)} ); } let errorContextElement = null; if (scriptUnitTestResult.context) { errorContextElement = ( Received unexpected output:{' '} {JSON.stringify(scriptUnitTestResult.context)} ); } return ( {errorStringElement} {errorContextElement} ); } return null; }; const scriptUnitTestEditorElement = () => { if (currentScriptUnitTest) { let previousButtonDisable = true; if (currentScriptUnitTestIndex > 0) { previousButtonDisable = false; } let nextButtonDisable = true; const unitTestsModdleElements = getScriptUnitTestElements(scriptElement); if (currentScriptUnitTestIndex < unitTestsModdleElements.length - 1) { nextButtonDisable = false; } // unset current unit test if all tests were deleted if (unitTestsModdleElements.length < 1) { setCurrentScriptUnitTest(null); setCurrentScriptUnitTestIndex(-1); } let scriptUnitTestResultBoolElement = null; if (scriptUnitTestResult) { scriptUnitTestResultBoolElement = ( {scriptUnitTestResult.result === true && ( )} {scriptUnitTestResult.result === false && ( )} ); } return (
{scriptUnitTestResultBoolElement} {unitTestFailureElement()}
Input Json:
Expected Output Json:
); } return null; }; const scriptEditor = () => { let scriptName = ''; if (scriptElement) { scriptName = (scriptElement as any).di.bpmnElement.name; } return ( Editing Script: {scriptName} {scriptUnitTestEditorElement()} ); }; const isDmn = () => { const fileName = params.file_name || ''; return searchParams.get('file_type') === 'dmn' || fileName.endsWith('.dmn'); }; const appropriateEditor = () => { if (isDmn()) { return ( ); } // let this be undefined (so we won't display the button) unless the // current primary_file_name is different from the one we're looking at. let onSetPrimaryFileCallback; if ( processModel && params.file_name && params.file_name !== processModel.primary_file_name ) { onSetPrimaryFileCallback = onSetPrimaryFile; } return ( ); }; // if a file name is not given then this is a new model and the ReactDiagramEditor component will handle it if (bpmnXmlForDiagramRendering || !params.file_name) { return ( <>

Process Model File {processModelFile ? `: ${(processModelFile as any).name}` : ''}

{appropriateEditor()} {newFileNameBox()} {scriptEditor()}
); } return null; }