diff --git a/src/management-system-v2/app/(dashboard)/processes/[processId]/page.tsx b/src/management-system-v2/app/(dashboard)/processes/[processId]/page.tsx index 71b65f282..7664e2cbf 100644 --- a/src/management-system-v2/app/(dashboard)/processes/[processId]/page.tsx +++ b/src/management-system-v2/app/(dashboard)/processes/[processId]/page.tsx @@ -7,7 +7,7 @@ import Modeler from '@/components/modeler'; import cn from 'classnames'; import Content from '@/components/content'; import Overlay from './overlay'; -import { useGetAsset } from '@/lib/fetch-data'; +import { useGetAsset, useInvalidateAsset } from '@/lib/fetch-data'; import { Breadcrumb, BreadcrumbProps, @@ -20,9 +20,11 @@ import { } from 'antd'; import { PlusOutlined } from '@ant-design/icons'; import useModelerStateStore from '@/lib/use-modeler-state-store'; -import { createNewProcessVersion } from '@/lib/helpers'; +import { createNewProcessVersion } from '@/lib/helpers/processVersioning'; import VersionCreationButton from '@/components/version-creation-button'; import Auth from '@/lib/AuthCanWrapper'; +import ProcessCreationButton from '@/components/process-creation-button'; +import { AuthCan } from '@/lib/iamComponents'; type ProcessProps = { params: { processId: string }; @@ -39,24 +41,23 @@ const Processes: FC = () => { const [closed, setClosed] = useState(false); const router = useRouter(); const modeler = useModelerStateStore((state) => state.modeler); - const { - isSuccess, - data: process, - refetch: refetchProcess, - isLoading: processIsLoading, - } = useGetAsset('/process/{definitionId}', { + const { data: process, isLoading: processIsLoading } = useGetAsset('/process/{definitionId}', { params: { path: { definitionId: processId as string } }, }); - const { - data: processes, - isLoading: processesIsLoading, - isError: processesIsError, - isSuccess: processesIsSuccess, - } = useGetAsset('/process', { + const { data: processes } = useGetAsset('/process', { params: { query: { noBpmn: true }, }, }); + + const invalidateVersions = useInvalidateAsset('/process/{definitionId}/versions', { + params: { path: { definitionId: processId as string } }, + }); + + const invalidateProcesses = useInvalidateAsset('/process/{definitionId}', { + params: { path: { definitionId: processId as string } }, + }); + const { token: { fontSizeHeading1 }, } = theme.useToken(); @@ -75,10 +76,6 @@ const Processes: FC = () => { } }, [minimized]); - const createProcess = () => { - console.log('create process'); - }; - const createProcessVersion = async (values: { versionName: string; versionDescription: string; @@ -91,6 +88,8 @@ const Processes: FC = () => { values.versionName, values.versionDescription, ); + await invalidateVersions(); + await invalidateProcesses(); } }; @@ -117,12 +116,14 @@ const Processes: FC = () => { dropdownRender={(menu) => ( <> {menu} - - - - + + + + }> + Create new process + + + )} options={processes?.map(({ definitionId, definitionName }) => ({ diff --git a/src/management-system-v2/components/ProcessSider.tsx b/src/management-system-v2/components/ProcessSider.tsx new file mode 100644 index 000000000..4d8630793 --- /dev/null +++ b/src/management-system-v2/components/ProcessSider.tsx @@ -0,0 +1,74 @@ +'use client'; + +import { FC, PropsWithChildren } from 'react'; +import { Menu } from 'antd'; +const { SubMenu, Item, ItemGroup } = Menu; +import { EditOutlined, ProfileOutlined, FileAddOutlined, StarOutlined } from '@ant-design/icons'; +import { usePathname, useRouter } from 'next/navigation'; +import { useAuthStore } from '@/lib/iam'; +import ProcessCreationButton from './process-creation-button'; + +const ProcessSider: FC = () => { + const router = useRouter(); + const activeSegment = usePathname().slice(1) || 'processes'; + const ability = useAuthStore((state) => state.ability); + + return ( + <> + + {ability.can('view', 'Process') ? ( + { + router.push(`/processes`); + }} + > + Process List + + } + className={activeSegment === 'processes' ? 'SelectedSegment' : ''} + icon={ + { + router.push(`/processes`); + }} + /> + } + > + } + hidden={!ability.can('create', 'Process')} + > + New Process} + > + + }> + Favorites + + + ) : null} + + {ability.can('view', 'Template') ? ( + }> + } + hidden={!ability.can('create', 'Template')} + > + New Template + + }> + Favorites + + + ) : null} + + + ); +}; + +export default ProcessSider; diff --git a/src/management-system-v2/components/layout.tsx b/src/management-system-v2/components/layout.tsx index 422cac39e..f7f3f7423 100644 --- a/src/management-system-v2/components/layout.tsx +++ b/src/management-system-v2/components/layout.tsx @@ -2,44 +2,15 @@ import styles from './layout.module.scss'; import { FC, PropsWithChildren, useState } from 'react'; -import { Layout as AntLayout, Grid, Menu, MenuProps } from 'antd'; -const { SubMenu, Item, Divider, ItemGroup } = Menu; -import Logo from '@/public/proceed.svg'; -import { - EditOutlined, - UnorderedListOutlined, - ProfileOutlined, - FileAddOutlined, - SettingOutlined, - ApiOutlined, - UserOutlined, - StarOutlined, - UnlockOutlined, -} from '@ant-design/icons'; +import { Layout as AntLayout, Grid, Menu } from 'antd'; +const { Item, Divider, ItemGroup } = Menu; +import { SettingOutlined, ApiOutlined, UserOutlined, UnlockOutlined } from '@ant-design/icons'; import Image from 'next/image'; -import { usePathname, useRouter } from 'next/navigation'; +import { usePathname } from 'next/navigation'; import cn from 'classnames'; import { useAuthStore } from '@/lib/iam'; import Link from 'next/link'; - -const items: MenuProps['items'] = [ - { - key: 'processes', - icon: , - label: 'Processes', - }, - { - key: 'projects', - icon: , - label: 'Projects', - }, - { - key: 'templates', - icon: , - label: 'Templates', - disabled: true, - }, -]; +import ProcessSider from './ProcessSider'; /** * The main layout of the application. It defines the sidebar and footer. Note @@ -51,7 +22,6 @@ const items: MenuProps['items'] = [ * page content in parallel routes. */ const Layout: FC = ({ children }) => { - const router = useRouter(); const activeSegment = usePathname().slice(1) || 'processes'; const [collapsed, setCollapsed] = useState(false); const ability = useAuthStore((state) => state.ability); @@ -88,68 +58,10 @@ const Layout: FC = ({ children }) => { {loggedIn ? ( - { - const path = key.split(':').at(-1); - router.push(`/${path}`); - }} - > + {ability.can('view', 'Process') || ability.can('view', 'Template') ? ( <> - - {ability.can('view', 'Process') ? ( - { - router.push(`/processes`); - }} - > - Process List - - } - className={activeSegment === 'processes' ? 'SelectedSegment' : ''} - icon={ - { - router.push(`/processes`); - }} - /> - } - > - } - hidden={!ability.can('create', 'Process')} - > - New Process - - }> - Favorites - - - ) : null} - - {ability.can('view', 'Template') ? ( - }> - } - hidden={!ability.can('create', 'Template')} - > - New Template - - }> - Favorites - - - ) : null} - - + ) : null} diff --git a/src/management-system-v2/components/modeler-toolbar.tsx b/src/management-system-v2/components/modeler-toolbar.tsx index 5f31da8ea..6ff5fa5e7 100644 --- a/src/management-system-v2/components/modeler-toolbar.tsx +++ b/src/management-system-v2/components/modeler-toolbar.tsx @@ -24,8 +24,10 @@ import { useParams, useSearchParams } from 'next/navigation'; import ProcessExportModal from './process-export'; -import { createNewProcessVersion } from '@/lib/helpers'; +import { createNewProcessVersion } from '@/lib/helpers/processVersioning'; import VersionCreationButton from './version-creation-button'; +import { useQueryClient } from '@tanstack/react-query'; +import { useInvalidateAsset } from '@/lib/fetch-data'; type ModelerToolbarProps = { onOpenXmlEditor: () => void; @@ -41,9 +43,18 @@ const ModelerToolbar: React.FC = ({ onOpenXmlEditor }) => { const modeler = useModelerStateStore((state) => state.modeler); const selectedElementId = useModelerStateStore((state) => state.selectedElementId); - // const [index, setIndex] = useState(0); const { processId } = useParams(); + const invalidateVersions = useInvalidateAsset('/process/{definitionId}/versions', { + params: { path: { definitionId: processId as string } }, + }); + + const invalidateProcesses = useInvalidateAsset('/process/{definitionId}', { + params: { path: { definitionId: processId as string } }, + }); + + // const [index, setIndex] = useState(0); + let selectedElement; if (modeler) { @@ -66,6 +77,8 @@ const ModelerToolbar: React.FC = ({ onOpenXmlEditor }) => { values.versionName, values.versionDescription, ); + await invalidateVersions(); + await invalidateProcesses(); } }; const handlePropertiesPanelToggle = () => { diff --git a/src/management-system-v2/components/modeler.tsx b/src/management-system-v2/components/modeler.tsx index fb6f3354f..6072e48ba 100644 --- a/src/management-system-v2/components/modeler.tsx +++ b/src/management-system-v2/components/modeler.tsx @@ -45,7 +45,6 @@ const Modeler: FC = ({ minimized, ...props }) => { const setModeler = useModelerStateStore((state) => state.setModeler); const setSelectedElementId = useModelerStateStore((state) => state.setSelectedElementId); - const editingDisabled = useModelerStateStore((state) => state.editingDisabled); /// Derived State const selectedVersionId = query.get('version'); @@ -61,7 +60,7 @@ const Modeler: FC = ({ minimized, ...props }) => { // This is not the most recent instance, so don't do anything. if (active !== modeler.current) return; - if (editingDisabled) { + if (selectedVersionId !== null) { modeler.current = new Viewer!({ container: canvas.current!, moddleExtensions: { @@ -103,7 +102,7 @@ const Modeler: FC = ({ minimized, ...props }) => { modeler.current?.destroy(); }; // only reset the modeler if we switch between editing being enabled or disabled - }, [setModeler, editingDisabled, processId, updateProcessMutation]); + }, [setModeler, selectedVersionId, processId, updateProcessMutation]); const { data: processBpmn } = useProcessBpmn(processId as string, selectedVersionId); diff --git a/src/management-system-v2/components/process-creation-button.tsx b/src/management-system-v2/components/process-creation-button.tsx new file mode 100644 index 000000000..4ccae2c83 --- /dev/null +++ b/src/management-system-v2/components/process-creation-button.tsx @@ -0,0 +1,72 @@ +'use client'; + +import React, { ReactNode, useState } from 'react'; + +import { Button } from 'antd'; +import type { ButtonProps } from 'antd'; +import ProcessModal from './process-modal'; +import { usePostAsset } from '@/lib/fetch-data'; +import { createProcess } from '@/lib/helpers/processHelpers'; + +type ProcessCreationButtonProps = ButtonProps & { + createProcess?: (values: { name: string; description: string }) => any; + wrapperElement?: ReactNode; +}; + +/** + * + * Button to create Processes including a Modal for inserting needed values. Alternatively, a custom wrapper element can be used instead of a button. + * Custom function for creation of process using inserted values can be applied + */ +const ProcessCreationButton: React.FC = ({ + createProcess: customCreateProcess, + wrapperElement, + ...props +}) => { + const [isProcessModalOpen, setIsProcessModalOpen] = useState(false); + const { mutateAsync: postProcess } = usePostAsset('/process'); + + const createNewProcess = async (values: { name: string; description: string }) => { + const { metaInfo, bpmn } = await createProcess(values); + try { + await postProcess({ + body: { bpmn: bpmn, departments: [] }, + }); + } catch (err) { + console.log(err); + } + }; + + return ( + <> + {wrapperElement ? ( +
{ + setIsProcessModalOpen(true); + }} + > + {wrapperElement} +
+ ) : ( + + )} + { + setIsProcessModalOpen(false); + + if (values) { + customCreateProcess ? customCreateProcess(values) : createNewProcess(values); + } + }} + show={isProcessModalOpen} + > + + ); +}; + +export default ProcessCreationButton; diff --git a/src/management-system-v2/components/process-modal.tsx b/src/management-system-v2/components/process-modal.tsx new file mode 100644 index 000000000..2e918ca22 --- /dev/null +++ b/src/management-system-v2/components/process-modal.tsx @@ -0,0 +1,90 @@ +'use client'; + +import React, { useState } from 'react'; + +import { Button, Modal, Form, Input } from 'antd'; + +import { FormInstance } from 'antd/es/form'; + +const ModalSubmitButton = ({ form, onSubmit }: { form: FormInstance; onSubmit: Function }) => { + const [submittable, setSubmittable] = useState(false); + + // Watch all values + const values = Form.useWatch([], form); + + React.useEffect(() => { + form.validateFields({ validateOnly: true }).then( + () => { + setSubmittable(true); + }, + () => { + setSubmittable(false); + }, + ); + }, [form, values]); + + return ( + + ); +}; + +type ProcessModalProps = { + show: boolean; + close: (values?: { name: string; description: string }) => void; +}; +const ProcessModal: React.FC = ({ show, close }) => { + const [form] = Form.useForm(); + + return ( + { + close(); + }} + footer={[ + , + , + ]} + > +
+ + + + + + +
+
+ ); +}; + +export default ProcessModal; diff --git a/src/management-system-v2/components/version-toolbar.tsx b/src/management-system-v2/components/version-toolbar.tsx index ea90037ea..8296ceccf 100644 --- a/src/management-system-v2/components/version-toolbar.tsx +++ b/src/management-system-v2/components/version-toolbar.tsx @@ -1,112 +1,59 @@ 'use client'; -const { +import { setDefinitionsId, setDefinitionsName, manipulateElementsByTagName, generateDefinitionsId, -} = require('@proceed/bpmn-helper'); + getUserTaskFileNameMapping, +} from '@proceed/bpmn-helper'; -import React, { useEffect, useState } from 'react'; +import React, { useState } from 'react'; import type ElementRegistry from 'diagram-js/lib/core/ElementRegistry'; -import { Tooltip, Button, Space, Modal, Form, Input } from 'antd'; +import { Tooltip, Button, Space, Modal } from 'antd'; import { FormOutlined, PlusOutlined } from '@ant-design/icons'; import useModelerStateStore from '@/lib/use-modeler-state-store'; -import { useParams } from 'next/navigation'; -import { FormInstance } from 'antd/es/form'; -import { useGetAsset, post } from '@/lib/fetch-data'; - -const ModalSubmitButton = ({ form, onSubmit }: { form: FormInstance; onSubmit: Function }) => { - const [submittable, setSubmittable] = useState(false); - - // Watch all values - const values = Form.useWatch([], form); - - React.useEffect(() => { - form.validateFields({ validateOnly: true }).then( - () => { - setSubmittable(true); - }, - () => { - setSubmittable(false); - }, - ); - }, [form, values]); - - return ( - - ); -}; - -type ProcessModalProps = { +import { useParams, useRouter } from 'next/navigation'; +import { get, del, put, usePostAsset } from '@/lib/fetch-data'; +import { convertToEditableBpmn } from '@/lib/helpers/processVersioning'; +import { asyncForEach, asyncMap } from '@/lib/helpers/javascriptHelpers'; +import ProcessCreationButton from './process-creation-button'; +import { AuthCan } from '@/lib/iamComponents'; + +type ConfirmationModalProps = { show: boolean; - close: (values?: { name: string; description: string }) => void; + close: () => void; + confirm: () => void; }; -const ProcessModal: React.FC = ({ show, close }) => { - const [form] = Form.useForm(); - +const ConfirmationModal: React.FC = ({ show, close, confirm }) => { return ( { + confirm(); + }} onCancel={() => { close(); }} - footer={[ - , - , - ]} > -
- - - - - - -
+

Any changes that are not stored in another version are irrecoverably lost!

); }; type VersionToolbarProps = {}; const VersionToolbar: React.FC = () => { - const [isProcessModalOpen, setIsProcessModalOpen] = useState(false); - + const [isConfirmationModalOpen, setIsConfirmationModalOpen] = useState(false); + const router = useRouter(); const modeler = useModelerStateStore((state) => state.modeler); const selectedElementId = useModelerStateStore((state) => state.selectedElementId); + const { mutateAsync: postProcess } = usePostAsset('/process'); // const [index, setIndex] = useState(0); const { processId } = useParams(); @@ -121,31 +68,128 @@ const VersionToolbar: React.FC = () => { : elementRegistry.getAll().filter((el) => el.businessObject.$type === 'bpmn:Process')[0]; } - const openNewProcessModal = () => { - setIsProcessModalOpen(true); + const openConfirmationModal = () => { + setIsConfirmationModalOpen(true); }; const createNewProcess = async (values: { name: string; description: string }) => { const saveXMLResult = await modeler?.saveXML({ format: true }); if (saveXMLResult?.xml) { - const bpmn = saveXMLResult.xml; - const defId = generateDefinitionsId(); - let newBpmn = await setDefinitionsId(bpmn, defId); - newBpmn = await setDefinitionsName(newBpmn, values.name); - newBpmn = await manipulateElementsByTagName( - newBpmn, - 'bpmn:Definitions', - (definitions: any) => { - delete definitions.version; - delete definitions.versionName; - delete definitions.versionDescription; - delete definitions.versionBasedOn; + try { + const bpmn = saveXMLResult.xml; + const defId = generateDefinitionsId(); + let newBpmn = await setDefinitionsId(bpmn, defId); + newBpmn = await setDefinitionsName(newBpmn, values.name); + newBpmn = (await manipulateElementsByTagName( + newBpmn, + 'bpmn:Definitions', + (definitions: any) => { + delete definitions.version; + delete definitions.versionName; + delete definitions.versionDescription; + delete definitions.versionBasedOn; + }, + )) as string; + + await postProcess({ + body: { bpmn: newBpmn, departments: [] }, + }); + } catch (err) { + console.log(err); + } + } + }; + + const getUsedFileNames = async (bpmn: string) => { + const userTaskFileNameMapping = await getUserTaskFileNameMapping(bpmn); + + const fileNames = new Set(); + + Object.values(userTaskFileNameMapping).forEach(({ fileName }) => { + if (fileName) { + fileNames.add(fileName); + } + }); + + return [...fileNames]; + }; + + const getHtmlMappingByFileName = async () => { + // Retrieve all stored userTask fileNames and corresponding html + const { data } = await get('/process/{definitionId}/user-tasks', { + params: { path: { definitionId: processId as string } }, + }); + const existingUserTaskFileNames = data || []; + + const htmlMappingByFileName = {} as { [userTaskId: string]: string }; + await asyncForEach(existingUserTaskFileNames, async (existingUserTaskFileName) => { + const { data: html } = await get('/process/{definitionId}/user-tasks/{userTaskFileName}', { + params: { + path: { + definitionId: processId as string, + userTaskFileName: existingUserTaskFileName, + }, }, - ); - const response = await post('/process', { - body: { bpmn: newBpmn, /*description: values.description,*/ departments: [] }, parseAs: 'text', }); + + if (html) { + htmlMappingByFileName[existingUserTaskFileName] = html; + } + }); + return htmlMappingByFileName; + }; + + const setAsLatestVersion = async () => { + const saveXMLResult = await modeler?.saveXML({ format: true }); + + // Retrieve editable bpmn of latest version + const { data: editableProcessData } = await get('/process/{definitionId}', { + params: { path: { definitionId: processId as string } }, + }); + const editableBpmn = editableProcessData?.bpmn; + + if (saveXMLResult?.xml && editableBpmn) { + const currentVersionBpmn = saveXMLResult.xml; + + const htmlMappingByFileName = await getHtmlMappingByFileName(); + + const { bpmn: convertedBpmn, changedFileNames } = + await convertToEditableBpmn(currentVersionBpmn); + + // Delete UserTasks stored for latest version + const fileNamesInEditableVersion = await getUsedFileNames(editableBpmn); + await asyncMap(fileNamesInEditableVersion, async (fileNameInEditableVersion: string) => { + await del('/process/{definitionId}/user-tasks/{userTaskFileName}', { + params: { + path: { + definitionId: processId as string, + userTaskFileName: fileNameInEditableVersion, + }, + }, + }); + }); + + // Store UserTasks from this version as UserTasks from latest version + await asyncMap(Object.entries(changedFileNames), async ([oldName, newName]) => { + await put('/process/{definitionId}/user-tasks/{userTaskFileName}', { + params: { + path: { definitionId: processId as string, userTaskFileName: newName }, + }, + body: htmlMappingByFileName[oldName], + headers: new Headers({ + 'Content-Type': 'text/plain', + }), + }); + }); + + // Store bpmn from this version as latest version + await put('/process/{definitionId}', { + params: { path: { definitionId: processId as string } }, + body: { bpmn: convertedBpmn }, + }); + + router.push(`/processes/${processId as string}`); } }; @@ -153,24 +197,29 @@ const VersionToolbar: React.FC = () => { <>
- - - + + + } + createProcess={createNewProcess} + > + + - +
- { - setIsProcessModalOpen(false); - - if (values) { - createNewProcess(values); - } + { + setIsConfirmationModalOpen(false); + }} + confirm={() => { + setAsLatestVersion(); + setIsConfirmationModalOpen(false); }} - show={isProcessModalOpen} - > + show={isConfirmationModalOpen} + > ); }; diff --git a/src/management-system-v2/lib/helpers/javascriptHelpers.ts b/src/management-system-v2/lib/helpers/javascriptHelpers.ts new file mode 100644 index 000000000..dc62d363c --- /dev/null +++ b/src/management-system-v2/lib/helpers/javascriptHelpers.ts @@ -0,0 +1,24 @@ +export async function asyncMap(array: Array, cb: (entry: any, index: Number) => Promise) { + const mappingCallbacks = array.map(async (entry, index) => await cb(entry, index)); + + const mappedValues = await Promise.all(mappingCallbacks); + + return mappedValues; +} + +export async function asyncForEach( + array: Array, + cb: (entry: any, index: Number) => Promise, +) { + await asyncMap(array, cb); +} + +export async function asyncFilter(array: Array, cb: (entry: any) => Promise) { + // map the elements to their value or undefined and then filter undefined entries + return ( + await asyncMap(array, async (entry) => { + const keep = await cb(entry); + return keep ? entry : undefined; + }) + ).filter((entry) => entry); +} diff --git a/src/management-system-v2/lib/helpers/processHelpers.ts b/src/management-system-v2/lib/helpers/processHelpers.ts new file mode 100644 index 000000000..f67a8cce2 --- /dev/null +++ b/src/management-system-v2/lib/helpers/processHelpers.ts @@ -0,0 +1,181 @@ +import { + toBpmnObject, + toBpmnXml, + getElementsByTagName, + generateDefinitionsId, + setDefinitionsId, + setStandardDefinitions, + setTargetNamespace, + addDocumentationToProcessObject, + getProcessDocumentationByObject, + getExporterName, + getExporterVersion, + getDefinitionsInfos, + initXml, + getDefinitionsName, + setDefinitionsName, + getOriginalDefinitionsId, + generateProcessId, + getIdentifyingInfos, +} from '@proceed/bpmn-helper'; + +interface ProceedProcess { + id?: string; + type?: string; + originalId?: string; + name?: string; + description?: string; + processIds?: string[]; + variables?: { name: string; type: string }[]; + departments?: string[]; + inEditingBy?: { id: string; task: string | null }[]; + createdOn?: string; + lastEdited?: string; + shared?: boolean; + versions?: Array; +} + +interface ProcessInfo { + bpmn: string; + metaInfo: ProceedProcess; +} + +/** + * Creates a default process meta object containing all fields we expect a process meta object to have + * + */ +export function getDefaultProcessMetaInfo() { + const date = new Date().toUTCString(); + return { + id: '', + type: 'process', + originalId: '', + name: 'Default Process', + description: '', + owner: null, + processIds: [], + variables: [], + departments: [], + inEditingBy: [], + createdOn: date, + lastEdited: date, + shared: false, + versions: [], + } as ProceedProcess; +} + +/** + * Creates a new proceed process either from a given bpmn or from a default bpmn template + * creates a bpmn and a meta info object + */ +export async function createProcess( + processInfo: ProceedProcess & { bpmn?: string }, + noDefaults: boolean = false, +) { + let metaInfo = { ...processInfo }; + delete metaInfo.bpmn; + + // create default bpmn if user didn't provide any + let bpmn = processInfo.bpmn || initXml(); + + let definitions; + + try { + const xmlObj = await toBpmnObject(bpmn); + [definitions] = getElementsByTagName(xmlObj, 'bpmn:Definitions'); + } catch (err) { + throw new Error(`Invalid bpmn: ${err}`); + } + + // if we import a process not created in proceed we set the id to a proceed conform id + const { exporter, id: importDefinitionsId } = await getDefinitionsInfos(definitions); + if ( + exporter !== getExporterName() && + (!processInfo.id || processInfo.id === importDefinitionsId) + ) { + processInfo.id = generateDefinitionsId(); + } + + if (!processInfo.name) { + // try to get name from bpmn object + metaInfo.name = (await getDefinitionsName(definitions))!; + } + + setStandardDefinitions(definitions, getExporterName(), getExporterVersion()); + + if (!metaInfo.name) { + throw new Error( + 'No name provided (name can be provided in the general information or in the definitions of the given bpmn)', + ); + } + + // specifically provided id takes precedence over existing id and if there is none a new one is created + metaInfo.id = processInfo.id || importDefinitionsId || generateDefinitionsId(); + + await setDefinitionsId(definitions, metaInfo.id); + await setDefinitionsName(definitions, metaInfo.name); + + if (!processInfo.originalId) { + metaInfo.originalId = (await getOriginalDefinitionsId(definitions))!; + } + + await setTargetNamespace(definitions, metaInfo.id); + + const processes = getElementsByTagName(definitions, 'bpmn:Process'); + + // make sure every process has an id + processes.forEach((p) => { + if (!p.id) { + p.id = generateProcessId(); + } + }); + + metaInfo.processIds = processes.map((p) => p.id); + + const [process] = processes; + + // if the user gave a process description make sure to write it into bpmn + if (process && processInfo.hasOwnProperty('description')) { + addDocumentationToProcessObject(process, processInfo.description); + } + + metaInfo.description = getProcessDocumentationByObject(process); + + bpmn = await toBpmnXml(definitions); + + if (!noDefaults) { + // make sure metaInfo has all necessary entries for a process meta object + metaInfo = { ...getDefaultProcessMetaInfo(), ...metaInfo }; + } + + return { metaInfo, bpmn } as ProcessInfo; +} + +/** + * Parses all necessary information from a process description + * + */ +export async function getProcessInfo(bpmn: string) { + if (!bpmn || typeof bpmn !== 'string') { + throw new Error(`Expected given bpmn to be of type string but got ${typeof bpmn} instead!`); + } + + let definitions; + try { + definitions = await toBpmnObject(bpmn); + } catch (err) { + throw new Error(`Given process description is invalid. Reason:\n${err}`); + } + + const metadata = await getIdentifyingInfos(definitions); + + if (!metadata.id) { + throw new Error('Process definitions do not contain an id.'); + } + + if (!metadata.name) { + throw new Error('Process definitions do not contain a name.'); + } + + return metadata; +} diff --git a/src/management-system-v2/lib/helpers.ts b/src/management-system-v2/lib/helpers/processVersioning.ts similarity index 70% rename from src/management-system-v2/lib/helpers.ts rename to src/management-system-v2/lib/helpers/processVersioning.ts index ec1de7525..0e5de23d2 100644 --- a/src/management-system-v2/lib/helpers.ts +++ b/src/management-system-v2/lib/helpers/processVersioning.ts @@ -1,5 +1,3 @@ -import { ApiData, get, put, post } from './fetch-data'; - import { toBpmnObject, toBpmnXml, @@ -11,16 +9,13 @@ import { setUserTaskData, } from '@proceed/bpmn-helper'; +import { ApiData, get, put, post } from '../fetch-data'; + +import { asyncForEach, asyncMap } from './javascriptHelpers'; + const { diff } = require('bpmn-js-differ'); -/** - * Will compare two bpmns to check if both are equal (with possible differences in their versions) - * - * @param {string} bpmn - * @param {string} otherBpmn - * @returns {boolean} - */ -async function areVersionsEqual(bpmn: string, otherBpmn: string) { +export async function areVersionsEqual(bpmn: string, otherBpmn: string) { const bpmnObj = await toBpmnObject(bpmn); const otherBpmnObj = await toBpmnObject(otherBpmn); @@ -54,6 +49,30 @@ async function areVersionsEqual(bpmn: string, otherBpmn: string) { return false; } +export async function convertToEditableBpmn(bpmn: string) { + let bpmnObj = await toBpmnObject(bpmn); + + const { version } = await getDefinitionsVersionInformation(bpmnObj); + + bpmnObj = (await setDefinitionsVersionInformation(bpmnObj, { + versionBasedOn: version, + })) as object; + + const changedFileNames = {} as { [key: string]: string }; + + const fileNameMapping = await getUserTaskFileNameMapping(bpmnObj); + + await asyncForEach(Object.entries(fileNameMapping), async ([userTaskId, { fileName }]) => { + if (fileName) { + const [unversionedName] = fileName.split('-'); + changedFileNames[fileName] = unversionedName; + await setUserTaskData(bpmnObj, userTaskId, unversionedName); + } + }); + + return { bpmn: await toBpmnXml(bpmnObj), changedFileNames }; +} + async function getLocalVersionBpmn( process: ApiData<'/process', 'get'>[number], localVersion: number, @@ -82,19 +101,21 @@ async function versionUserTasks( const { versionBasedOn } = await getDefinitionsVersionInformation(bpmnObj); for (let userTaskId in htmlMapping) { + const { fileName, implementation } = htmlMapping[userTaskId]; // only version user tasks that use html - if (htmlMapping[userTaskId].implementation === getUserTaskImplementationString()) { - const { data: html } = await get('/process/{definitionId}/user-tasks/{userTaskFileName}', { + if (fileName && implementation === getUserTaskImplementationString()) { + const { data } = await get('/process/{definitionId}/user-tasks/{userTaskFileName}', { params: { path: { definitionId: processInfo.definitionId, - userTaskFileName: htmlMapping[userTaskId].fileName!, + userTaskFileName: fileName, }, }, parseAs: 'text', }); + const userTaskHTML = data!; - let fileName = `${htmlMapping[userTaskId].fileName}-${newVersion}`; + let versionFileName = `${fileName}-${newVersion}`; // get the html of the user task in the based on version (if there is one and it is locally known) const basedOnBPMN = @@ -109,34 +130,48 @@ async function versionUserTasks( // check if the user task existed and if it had the same html const basedOnVersionFileInfo = basedOnVersionHtmlMapping[userTaskId]; - const { data: basedOnVersionUserTaskHTML } = await get( - '/process/{definitionId}/user-tasks/{userTaskFileName}', - { - params: { - path: { - definitionId: processInfo.definitionId, - userTaskFileName: basedOnVersionFileInfo.fileName!, + + if (basedOnVersionFileInfo && basedOnVersionFileInfo.fileName) { + const { data: basedOnVersionUserTaskHTML } = await get( + '/process/{definitionId}/user-tasks/{userTaskFileName}', + { + params: { + path: { + definitionId: processInfo.definitionId, + userTaskFileName: basedOnVersionFileInfo.fileName, + }, }, + parseAs: 'text', }, - parseAs: 'text', - }, - ); + ); - if (basedOnVersionFileInfo && basedOnVersionUserTaskHTML === html) { - // reuse the html of the previous version - userTaskHtmlAlreadyExisting = true; - fileName = basedOnVersionFileInfo.fileName!; + if (basedOnVersionUserTaskHTML === userTaskHTML) { + // reuse the html of the previous version + userTaskHtmlAlreadyExisting = true; + versionFileName = basedOnVersionFileInfo.fileName; + } } } // make sure the user task is using the correct data - await setUserTaskData(bpmnObj, userTaskId, fileName, getUserTaskImplementationString()); + await setUserTaskData( + bpmnObj, + userTaskId, + versionFileName, + getUserTaskImplementationString(), + ); // store the user task version if it didn't exist before if (!dryRun && !userTaskHtmlAlreadyExisting) { await put('/process/{definitionId}/user-tasks/{userTaskFileName}', { - params: { path: { definitionId: processInfo.definitionId, userTaskFileName: fileName } }, - body: html, + params: { + path: { definitionId: processInfo.definitionId, userTaskFileName: versionFileName }, + }, + body: userTaskHTML, + headers: new Headers({ + 'Content-Type': 'text/plain', + }), + parseAs: 'text', }); } }