import { useContext, useEffect, useRef, useState } from 'react'; import { generatePath, useNavigate, useParams, useSearchParams, } from 'react-router-dom'; // @ts-ignore import { Button, Modal, Content, Tabs, TabList, Tab, TabPanels, TabPanel } from '@carbon/react'; import Row from 'react-bootstrap/Row'; import Col from 'react-bootstrap/Col'; import Editor, { DiffEditor } from '@monaco-editor/react'; import MDEditor from '@uiw/react-md-editor'; import ReactDiagramEditor from '../components/ReactDiagramEditor'; import ProcessBreadcrumb from '../components/ProcessBreadcrumb'; import HttpService from '../services/HttpService'; import ErrorContext from '../contexts/ErrorContext'; import { makeid, modifyProcessIdentifierForPathParam } from '../helpers'; import { CarbonComboBoxProcessSelection, ProcessFile, ProcessModel, ProcessReference, } from '../interfaces'; import ProcessSearch from '../components/ProcessSearch'; import { Notification } from '../components/Notification'; export default function ProcessModelEditDiagram() { const [showFileNameEditor, setShowFileNameEditor] = useState(false); const handleShowFileNameEditor = () => setShowFileNameEditor(true); const [processModel, setProcessModel] = useState(null); const [scriptText, setScriptText] = useState(''); const [scriptType, setScriptType] = useState(''); const [scriptEventBus, setScriptEventBus] = useState(null); const [scriptModeling, setScriptModeling] = useState(null); const [scriptElement, setScriptElement] = useState(null); const [showScriptEditor, setShowScriptEditor] = useState(false); const handleShowScriptEditor = () => setShowScriptEditor(true); const [markdownText, setMarkdownText] = useState(''); const [markdownEventBus, setMarkdownEventBus] = useState(null); const [showMarkdownEditor, setShowMarkdownEditor] = useState(false); const [showProcessSearch, setShowProcessSearch] = useState(false); const [processSearchEventBus, setProcessSearchEventBus] = useState(null); const [processSearchElement, setProcessSearchElement] = useState(null); const [processes, setProcesses] = useState([]); const handleShowMarkdownEditor = () => setShowMarkdownEditor(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 modifiedProcessModelId = modifyProcessIdentifierForPathParam( (params as any).process_model_id ); const processModelPath = `process-models/${modifiedProcessModelId}`; useEffect(() => { // Grab all available process models in case we need to search for them. // Taken from the Process Group List const processResults = (result: any) => { const selectionArray = result.map((item: any) => { const label = `${item.display_name} (${item.identifier})`; Object.assign(item, { label }); return item; }); setProcesses(selectionArray); }; HttpService.makeCallToBackend({ path: `/processes`, successCallback: processResults, }); }, [processModel]); useEffect(() => { const processResult = (result: ProcessModel) => { setProcessModel(result); }; HttpService.makeCallToBackend({ path: `/${processModelPath}`, successCallback: processResult, }); }, [processModelPath]); useEffect(() => { const fileResult = (result: any) => { setProcessModelFile(result); setBpmnXmlForDiagramRendering(result.file_contents); }; if (params.file_name) { HttpService.makeCallToBackend({ path: `/${processModelPath}/files/${params.file_name}`, successCallback: fileResult, }); } }, [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/${modifiedProcessModelId}/files/${fileNameWithExtension}` ); } }; const [displaySaveFileMessage, setDisplaySaveFileMessage] = useState(false); const saveDiagram = (bpmnXML: any, fileName = params.file_name) => { setErrorMessage(null); setBpmnXmlForDiagramRendering(bpmnXML); let url = `/process-models/${modifiedProcessModelId}/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(''); setDisplaySaveFileMessage(true); }; const onDeleteFile = (fileName = params.file_name) => { const url = `/process-models/${modifiedProcessModelId}/files/${fileName}`; const httpMethod = 'DELETE'; const navigateToProcessModelShow = (_httpResult: any) => { navigate(`/admin/process-models/${modifiedProcessModelId}`); }; HttpService.makeCallToBackend({ path: url, successCallback: navigateToProcessModelShow, httpMethod, }); }; const onSetPrimaryFile = (fileName = params.file_name) => { const url = `/process-models/${modifiedProcessModelId}`; 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 ( 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 onJsonFilesRequested = (event: any) => { if (processModel) { const jsonFiles = processModel.files.filter((f) => f.type === 'json'); const options = jsonFiles.map((f) => { return { label: f.name, value: f.name }; }); event.eventBus.fire('spiff.json_files.returned', { options }); } else { console.error('There is no process Model.'); } }; const onDmnFilesRequested = (event: any) => { if (processModel) { const dmnFiles = processModel.files.filter((f) => f.type === 'dmn'); const options: any[] = []; dmnFiles.forEach((file) => { file.references.forEach((ref) => { options.push({ label: ref.display_name, value: ref.identifier }); }); }); event.eventBus.fire('spiff.dmn_files.returned', { options }); } else { console.error('There is no process model.'); } }; 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, script: string, scriptTypeString: string, eventBus: any, modeling: any ) => { // TODO: modeling is only needed for script unit tests. // we should update this to act like updating scripts // where we pass an event to bpmn-js setScriptModeling(modeling); setScriptText(script || ''); setScriptType(scriptTypeString); setScriptEventBus(eventBus); setScriptElement(element); setScriptUnitTestElementWithIndex(0, element); handleShowScriptEditor(); }; const handleScriptEditorClose = () => { scriptEventBus.fire('spiff.script.update', { scriptType, script: scriptText, element: scriptElement, }); resetUnitTextResult(); setShowScriptEditor(false); }; const handleEditorScriptChange = (value: any) => { setScriptText(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 jsonEditorOptions = () => { return Object.assign(generalEditorOptions(), { minimap: { enabled: false }, folding: true }); } 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) { let inputJson = ''; let expectedJson = ''; try { inputJson = JSON.parse(currentScriptUnitTest.inputJson.value); expectedJson = JSON.parse(currentScriptUnitTest.expectedOutputJson.value); } catch (e) { setScriptUnitTestResult({ result:false, error:"The JSON provided contains a formatting error."}) return; } resetUnitTextResult(); HttpService.makeCallToBackend({ path: `/process-models/${modifiedProcessModelId}/script-unit-tests/run`, httpMethod: 'POST', successCallback: processScriptUnitTestRunResult, postBody: { bpmn_task_identifier: (scriptElement as any).id, python_script: scriptText, input_json: inputJson, expected_output_json: expectedJson }, }); } }; const unitTestFailureElement = () => { if ( scriptUnitTestResult && scriptUnitTestResult.result === false ) { let errorMessage = ''; if (scriptUnitTestResult.context) { errorMessage = 'Unexpected result. Please see the comparison below.'; } else if (scriptUnitTestResult.line_number) { errorMessage = `Error encountered running the script. Please check the code around line ${ scriptUnitTestResult.line_number}` } else { errorMessage = `Error encountered running the script. ${JSON.stringify(scriptUnitTestResult.error)}` } let errorStringElement = { errorMessage }; let errorContextElement = null; if (scriptUnitTestResult.context) { errorStringElement = ( Unexpected result. Please see the comparison below. ); let outputJson = '{}'; if (currentScriptUnitTest) { outputJson = JSON.stringify( JSON.parse(currentScriptUnitTest.expectedOutputJson.value), null, ' ' ); } const contextJson = JSON.stringify( scriptUnitTestResult.context, null, ' ' ); errorContextElement = ( ); } 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 && ( )} ); } let inputJson = currentScriptUnitTest.inputJson.value; let outputJson = currentScriptUnitTest.expectedOutputJson.value; try { inputJson = JSON.stringify( JSON.parse(currentScriptUnitTest.inputJson.value), null, ' ' ); outputJson = JSON.stringify( JSON.parse(currentScriptUnitTest.expectedOutputJson.value), null, ' ' ); } catch (e) { // Attemping to format the json failed -- it's invalid. } return (
{scriptUnitTestResultBoolElement} {unitTestFailureElement()}
Input Json:
Expected Output Json:
); } return null; }; const scriptEditor = () => { return ( ); }; const scriptEditorAndTests = () => { let scriptName = ''; if (scriptElement) { scriptName = (scriptElement as any).di.bpmnElement.name; } return ( Script Editor Unit Tests {scriptEditor()} {scriptUnitTestEditorElement()} ); }; const onLaunchMarkdownEditor = ( element: any, markdown: string, eventBus: any ) => { setMarkdownText(markdown || ''); setMarkdownEventBus(eventBus); handleShowMarkdownEditor(); }; const handleMarkdownEditorClose = () => { markdownEventBus.fire('spiff.markdown.update', { value: markdownText, }); setShowMarkdownEditor(false); }; const markdownEditor = () => { return ( ); }; const onSearchProcessModels = ( processId: string, eventBus: any, element: any ) => { setProcessSearchEventBus(eventBus); setProcessSearchElement(element); setShowProcessSearch(true); }; const processSearchOnClose = (selection: CarbonComboBoxProcessSelection) => { const selectedProcessModel = selection.selectedItem; if (selectedProcessModel) { processSearchEventBus.fire('spiff.callactivity.update', { element: processSearchElement, value: selectedProcessModel.identifier, }); } setShowProcessSearch(false); }; const processModelSelector = () => { return ( ); }; const findFileNameForReferenceId = ( id: string, type: string ): ProcessFile | null => { // Given a reference id (like a process_id, or decision_id) finds the file // that contains that reference and returns it. let matchFile = null; if (processModel) { const files = processModel.files.filter((f) => f.type === type); files.some((file) => { if (file.references.some((ref) => ref.identifier === id)) { matchFile = file; return true; } return false; }); } return matchFile; }; const onLaunchBpmnEditor = (processId: string) => { const processRef = processes.find((p) => { return p.identifier === processId; }); if (processRef) { const path = generatePath( '/admin/process-models/:process_model_path/files/:file_name', { process_model_path: modifyProcessIdentifierForPathParam( processRef.process_model_id ), file_name: processRef.file_name, } ); window.open(path); } }; const onLaunchJsonEditor = (fileName: string) => { const path = generatePath( '/admin/process-models/:process_model_id/form/:file_name', { process_model_id: params.process_model_id, file_name: fileName, } ); window.open(path); }; const onLaunchDmnEditor = (processId: string) => { const file = findFileNameForReferenceId(processId, 'dmn'); if (file) { const path = generatePath( '/admin/process-models/:process_model_id/files/:file_name', { process_model_id: params.process_model_id, file_name: file.name, } ); window.open(path); } }; 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 ( ); }; const saveFileMessage = () => { if (displaySaveFileMessage) { return ( setDisplaySaveFileMessage(false)} > Changes to the file were saved. ); } return null; }; // 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) && processModel) { const processModelFileName = processModelFile ? processModelFile.name : ''; return ( <>

Process Model File{processModelFile ? ': ' : ''} {processModelFileName}

{saveFileMessage()} {appropriateEditor()} {newFileNameBox()} {scriptEditorAndTests()} {markdownEditor()} {processModelSelector()}
); } return null; }