From 7b4a7c430b59e38819d14897352e2927d0750bf9 Mon Sep 17 00:00:00 2001 From: jjoderis <58050428+jjoderis@users.noreply.github.com> Date: Tue, 19 Dec 2023 08:46:51 +0100 Subject: [PATCH] Ms2/modeler selection export (#199) * Include elements in copy that are not directly selected but where a predecessor is selected * Merge branch 'main' of github.com:PROCEED-Labs/proceed into ms2/modeler-selection-export * Added vscode debug config for new MS frontend; Fixed: trying to get the DI of an element in a bpmn with an empty collapsed subprocess leads to an error * Handling selected elements when exporting a process from the modeler and the respective export option is selected. Related to #144 * When exporting from the modeler only export the currently open layer and if collapsed subprocesses or imports should be exported only those nested under the current layer. Related to #144 * Some minor cleanup in the code --- .vscode/launch.json | 7 + src/helper-modules/bpmn-helper/src/util.js | 4 +- .../components/modeler-toolbar.tsx | 33 +++- .../components/modeler.tsx | 6 +- .../components/process-export.tsx | 169 ++++++++++-------- .../lib/process-export/export-preparation.ts | 116 ++++++++++-- .../lib/process-export/image-export.ts | 34 ++-- .../lib/process-export/index.ts | 21 ++- .../lib/process-export/pdf-export.ts | 22 ++- .../lib/process-export/util.ts | 104 +++++++---- .../lib/typescript-utils.ts | 5 + 11 files changed, 373 insertions(+), 148 deletions(-) diff --git a/.vscode/launch.json b/.vscode/launch.json index 1c9a0d606..2a984b1df 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -56,6 +56,13 @@ "webpack:///src/*.vue": "${webRoot}/*.vue" } }, + { + "name": "MS2: Attach to web client", + "type": "chrome", + "request": "launch", + "url": "http://localhost:3000", + "webRoot": "${workspaceFolder}/src/management-system-v2" + }, { "type": "chrome", "request": "attach", diff --git a/src/helper-modules/bpmn-helper/src/util.js b/src/helper-modules/bpmn-helper/src/util.js index db81b593b..afefdc997 100644 --- a/src/helper-modules/bpmn-helper/src/util.js +++ b/src/helper-modules/bpmn-helper/src/util.js @@ -59,6 +59,8 @@ function getChildren(travObj) { 'imports', 'extensionElements', 'participants', + 'laneSets', + 'lanes', ]; const allChildren = childNodeTypes @@ -120,7 +122,7 @@ function getElementDI(element, definitions) { } for (const diagram of definitions.diagrams) { - for (const planeElement of diagram.plane.planeElement) { + for (const planeElement of diagram.plane.planeElement || []) { if (planeElement.bpmnElement === element) { return planeElement; } diff --git a/src/management-system-v2/components/modeler-toolbar.tsx b/src/management-system-v2/components/modeler-toolbar.tsx index b6d9b3b59..2a7c3ae15 100644 --- a/src/management-system-v2/components/modeler-toolbar.tsx +++ b/src/management-system-v2/components/modeler-toolbar.tsx @@ -4,6 +4,10 @@ import React, { useEffect, useState } from 'react'; import type ElementRegistry from 'diagram-js/lib/core/ElementRegistry'; import type CommandStack from 'diagram-js/lib/command/CommandStack'; +import type Selection from 'diagram-js/lib/features/selection/Selection'; +import type Canvas from 'diagram-js/lib/core/Canvas'; + +import { is as bpmnIs } from 'bpmn-js/lib/util/ModelUtil'; import { Tooltip, Button, Space } from 'antd'; import { Toolbar, ToolbarGroup } from './toolbar'; @@ -40,6 +44,8 @@ const ModelerToolbar: React.FC = ({ onOpenXmlEditor }) => { const [showPropertiesPanel, setShowPropertiesPanel] = useState(false); const [showProcessExportModal, setShowProcessExportModal] = useState(false); + const [elementsSelectedForExport, setElementsSelectedForExport] = useState([]); + const [rootLayerIdForExport, setRootLayerIdForExport] = useState(undefined); const [canUndo, setCanUndo] = useState(false); const [canRedo, setCanRedo] = useState(false); @@ -108,6 +114,22 @@ const ModelerToolbar: React.FC = ({ onOpenXmlEditor }) => { }; const handleProcessExportModalToggle = async () => { + if (!showProcessExportModal && modeler?.get) { + // provide additional information for the export that is used if the user decides to only export selected elements (also controls if the option is given in the first place) + const selectedElementIds = (modeler.get('selection') as Selection).get().map(({ id }) => id); + setElementsSelectedForExport(selectedElementIds); + // provide additional information for the export so only the parts of the process that can be reached from the currently open layer are exported + const currentRootElement = (modeler.get('canvas') as Canvas).getRootElement(); + setRootLayerIdForExport( + bpmnIs(currentRootElement, 'bpmn:SubProcess') + ? currentRootElement.businessObject?.id + : undefined, + ); + } else { + setElementsSelectedForExport([]); + setRootLayerIdForExport(undefined); + } + setShowProcessExportModal(!showProcessExportModal); }; @@ -165,12 +187,21 @@ const ModelerToolbar: React.FC = ({ onOpenXmlEditor }) => { )} setShowProcessExportModal(false)} + giveSelectionOption={!!elementsSelectedForExport.length} /> ); diff --git a/src/management-system-v2/components/modeler.tsx b/src/management-system-v2/components/modeler.tsx index 59d9c6254..bef111c29 100644 --- a/src/management-system-v2/components/modeler.tsx +++ b/src/management-system-v2/components/modeler.tsx @@ -103,16 +103,16 @@ const Modeler = ({ processBpmn, versionName, process, ...divProps }: ModelerProp setEditingDisabled(false); } - // allow keyboard shortcuts like copy (strg+c) and paste (strg+v) etc. + // allow keyboard shortcuts like copy (ctrl+c) and paste (ctrl+v) etc. (modeler.current.get('keyboard') as any).bind(document); // create a custom copy behaviour where the whole process or selected parts can be copied to the clipboard as an image (modeler.current.get('keyboard') as any).addListener( - async (_: any, events: { keyEvent: KeyboardEvent }) => { + (_: any, events: { keyEvent: KeyboardEvent }) => { const { keyEvent } = events; // handle the copy shortcut if (keyEvent.ctrlKey && keyEvent.key === 'c' && modeler.current) { - await copyProcessImage(modeler.current); + copyProcessImage(modeler.current); } }, 'keyboard.keyup', diff --git a/src/management-system-v2/components/process-export.tsx b/src/management-system-v2/components/process-export.tsx index 0fe9b2289..6f37348b8 100644 --- a/src/management-system-v2/components/process-export.tsx +++ b/src/management-system-v2/components/process-export.tsx @@ -16,7 +16,7 @@ import { import type { CheckboxValueType } from 'antd/es/checkbox/Group'; import { exportProcesses } from '@/lib/process-export'; -import { ProcessExportOptions } from '@/lib/process-export/export-preparation'; +import { ProcessExportOptions, ExportProcessInfo } from '@/lib/process-export/export-preparation'; const exportTypeOptions = [ { label: 'BPMN', value: 'bpmn' }, @@ -25,78 +25,97 @@ const exportTypeOptions = [ { label: 'PNG', value: 'png' }, ]; -const exportSubOptions = { - bpmn: [ - { - label: 'with artefacts', - value: 'artefacts', - tooltip: - 'Also export html and images used in User-Tasks and images used for other process elements', - }, - { - label: 'with referenced processes', - value: 'imports', - tooltip: 'Also export all referenced processes used in call-activities', - }, - ], - pdf: [ - { - label: 'with meta data', - value: 'metaData', - tooltip: 'Add process meta information to each page (process name, version, etc.)', - }, - { - label: 'A4 pages', - value: 'a4', - tooltip: 'Use A4 format for all pages (Scales down the process image if necessary)', - }, - { - label: 'with referenced processes', - value: 'imports', - tooltip: 'Also export all referenced processes used in call-activities', - }, - { - label: 'with collapsed subprocesses', - value: 'subprocesses', - tooltip: 'Also export content of all collapsed subprocesses', - }, - ], - svg: [ - { - label: 'with referenced processes', - value: 'imports', - tooltip: 'Also export all referenced processes used in call-activities', - }, - { - label: 'with collapsed subprocesses', - value: 'subprocesses', - tooltip: 'Also export content of all collapsed subprocesses', - }, - ], - png: [ - { - label: 'with referenced processes', - value: 'imports', - tooltip: 'Also export all referenced processes used in call-activities', - }, - { - label: 'with collapsed subprocesses', - value: 'subprocesses', - tooltip: 'Also export content of all collapsed subprocesses', - }, - ], -}; +function getSubOptions(giveSelectionOption?: boolean) { + const exportSubOptions = { + bpmn: [ + { + label: 'with artefacts', + value: 'artefacts', + tooltip: + 'Also export html and images used in User-Tasks and images used for other process elements', + }, + { + label: 'with referenced processes', + value: 'imports', + tooltip: 'Also export all referenced processes used in call-activities', + }, + ], + pdf: [ + { + label: 'with meta data', + value: 'metaData', + tooltip: 'Add process meta information to each page (process name, version, etc.)', + }, + { + label: 'A4 pages', + value: 'a4', + tooltip: 'Use A4 format for all pages (Scales down the process image if necessary)', + }, + { + label: 'with referenced processes', + value: 'imports', + tooltip: 'Also export all referenced processes used in call-activities', + }, + { + label: 'with collapsed subprocesses', + value: 'subprocesses', + tooltip: 'Also export content of all collapsed subprocesses', + }, + ], + svg: [ + { + label: 'with referenced processes', + value: 'imports', + tooltip: 'Also export all referenced processes used in call-activities', + }, + { + label: 'with collapsed subprocesses', + value: 'subprocesses', + tooltip: 'Also export content of all collapsed subprocesses', + }, + ], + png: [ + { + label: 'with referenced processes', + value: 'imports', + tooltip: 'Also export all referenced processes used in call-activities', + }, + { + label: 'with collapsed subprocesses', + value: 'subprocesses', + tooltip: 'Also export content of all collapsed subprocesses', + }, + ], + }; + + const selectionOption = { + label: 'limit to selection', + value: 'onlySelection', + tooltip: + 'Exclude elements from the image(s) that are not selected and not inside a selected element', + }; + + if (giveSelectionOption) { + exportSubOptions.png.push(selectionOption); + exportSubOptions.svg.push(selectionOption); + exportSubOptions.pdf.push(selectionOption); + } + + return exportSubOptions; +} type ProcessExportModalProps = { - processes: { definitionId: string; processVersion?: number | string }[]; // the processes to export; also used to decide if the modal should be opened + processes: ExportProcessInfo; // the processes to export onClose: () => void; open: boolean; + giveSelectionOption?: boolean; // if the user can select to limit the export to elements selected in the modeler (only usable in the modeler) }; const ProcessExportModal: React.FC = ({ processes = [], onClose, open, + giveSelectionOption, }) => { const [selectedType, setSelectedType] = useState(); const [selectedOptions, setSelectedOptions] = useState(['metaData']); @@ -113,6 +132,7 @@ const ProcessExportModal: React.FC = ({ const handleClose = () => { setIsExporting(false); + setSelectedOptions(selectedOptions.filter((el) => el !== 'onlySelection')); onClose(); }; @@ -121,12 +141,13 @@ const ProcessExportModal: React.FC = ({ await exportProcesses( { type: selectedType!, - artefacts: selectedOptions.some((el) => el === 'artefacts'), - subprocesses: selectedOptions.some((el) => el === 'subprocesses'), - imports: selectedOptions.some((el) => el === 'imports'), - metaData: selectedOptions.some((el) => el === 'metaData'), - a4: selectedOptions.some((el) => el === 'a4'), + artefacts: selectedOptions.includes('artefacts'), + subprocesses: selectedOptions.includes('subprocesses'), + imports: selectedOptions.includes('imports'), + metaData: selectedOptions.includes('metaData'), + a4: selectedOptions.includes('a4'), scaling: pngScalingFactor, + exportSelectionOnly: selectedOptions.includes('onlySelection'), }, processes, ); @@ -154,11 +175,13 @@ const ProcessExportModal: React.FC = ({ style={{ width: '100%' }} > - {(selectedType ? exportSubOptions[selectedType] : []).map(({ label, value, tooltip }) => ( - - {label} - - ))} + {(selectedType ? getSubOptions(giveSelectionOption)[selectedType] : []).map( + ({ label, value, tooltip }) => ( + + {label} + + ), + )} {selectedType === 'png' && ( diff --git a/src/management-system-v2/lib/process-export/export-preparation.ts b/src/management-system-v2/lib/process-export/export-preparation.ts index ad3a2466b..acb54264e 100644 --- a/src/management-system-v2/lib/process-export/export-preparation.ts +++ b/src/management-system-v2/lib/process-export/export-preparation.ts @@ -11,13 +11,18 @@ import { getMetaDataFromElement, getElementsByTagName, toBpmnObject, + getElementById, getElementDI, getDefinitionsVersionInformation, getDefinitionsAndProcessIdForEveryCallActivity, } from '@proceed/bpmn-helper'; -import { asyncForEach, asyncMap } from '../helpers/javascriptHelpers'; -import { getImageDimensions, getSVGFromBPMN } from './util'; +import { asyncMap, asyncFilter } from '../helpers/javascriptHelpers'; +import { getImageDimensions, getSVGFromBPMN, isSelectedOrInsideSelected } from './util'; + +import { is as bpmnIs } from 'bpmn-js/lib/util/ModelUtil'; + +import { ArrayEntryType } from '../typescript-utils'; /** * The options that can be used to select what should be exported @@ -30,12 +35,18 @@ export type ProcessExportOptions = { metaData: boolean; // (only pdf) if the process page should contain meta information about the process (name, version, [subprocess-id]) as text a4: boolean; // if an a4 format should be used for the pdf pages (pdf) scaling: number; // the scaling factor that should be used for png export + exportSelectionOnly: boolean; // if only selected elements (and their children) should be in the final export }; /** * The incoming information about the processes(and versions) to export */ -export type ExportProcessInfo = { definitionId: string; processVersion?: number | string }[]; +export type ExportProcessInfo = { + definitionId: string; + processVersion?: number | string; + selectedElements?: string[]; + rootSubprocessLayerId?: string; +}[]; /** * The data needed for the export of a specific process @@ -49,8 +60,9 @@ export type ProcessExportData = { name?: string; bpmn: string; isImport: boolean; - subprocesses: { id: string; name?: string }[]; + layers: { id?: string; name?: string }[]; imports: { definitionId: string; processVersion: string }[]; + selectedElements?: string[]; }; }; userTasks: { @@ -140,8 +152,9 @@ type ExportMap = { name?: string; bpmn: string; isImport: boolean; - subprocesses: { id: string; name?: string }[]; + layers: { id?: string; name?: string }[]; imports: { definitionId: string; processVersion: string }[]; + selectedElements?: string[]; }; }; userTasks: { @@ -183,7 +196,12 @@ async function getMaximumScalingFactor(exportData: ProcessesExportData) { */ async function ensureProcessInfo( exportData: ExportMap, - { definitionId, processVersion }: { definitionId: string; processVersion?: string | number }, + { + definitionId, + processVersion, + selectedElements, + rootSubprocessLayerId, + }: ArrayEntryType, isImport = false, ) { if (!exportData[definitionId]) { @@ -211,12 +229,22 @@ async function ensureProcessInfo( const versionBpmn = await getVersionBpmn(definitionId, processVersion); const versionInformation = await getDefinitionsVersionInformation(versionBpmn); + // add the default root process layer if there is no rootSubprocessLayer given + let rootLayer = { id: undefined }; + if (rootSubprocessLayerId) { + // get the info for the selected root subprocess layer and add it as the default layer (regardless of collapsed subprocesses being selected in the options) + const subprocessInfos = await getCollapsedSubprocessInfos(versionBpmn); + const rootLayerInfo = subprocessInfos.find(({ id }) => id === rootSubprocessLayerId); + if (rootLayerInfo) rootLayer = rootLayerInfo; + } + exportData[definitionId].versions[versionName] = { name: versionInformation.name, bpmn: versionBpmn, isImport, - subprocesses: [], + layers: [rootLayer], imports: [], + selectedElements, }; } } @@ -244,17 +272,55 @@ export async function prepareExport( while (processVersionsToAdd.length) { let newProcessVersionsToAdd: typeof processVersionsToAdd = []; // get the bpmn for all processes and their versions to export - for (const { definitionId, processVersion, isImport } of processVersionsToAdd) { - await ensureProcessInfo(exportData, { definitionId, processVersion }, isImport); + for (const { + definitionId, + processVersion, + selectedElements, + rootSubprocessLayerId, + isImport, + } of processVersionsToAdd) { + await ensureProcessInfo( + exportData, + { definitionId, processVersion, selectedElements, rootSubprocessLayerId }, + isImport, + ); // if the option to export referenced processes is selected make sure to fetch their information as well if (options.imports) { const versionName = getVersionName(processVersion); - const { bpmn, imports } = exportData[definitionId].versions[versionName]; + const { bpmn, imports, selectedElements, layers } = + exportData[definitionId].versions[versionName]; // check the bpmn for referenced processes const importInfo = await getDefinitionsAndProcessIdForEveryCallActivity(bpmn, true); + if (options.type !== 'bpmn') { + const bpmnObj = await toBpmnObject(bpmn); + + for (const callActivityId in importInfo) { + const callActivity = getElementById(bpmnObj, callActivityId) as any; + + const inUnexportedLayer = + // exlude if the importing call activity is in a nested collapsed subprocess but they are not exported + (!options.subprocesses && + bpmnIs(callActivity.$parent, 'bpmn:SubProcess') && + callActivity.$parent.id !== layers[0].id) || + // exlude if the call activity importing this is not nested under the current layer + (layers.length && + layers[0].id && + !(await isSelectedOrInsideSelected(bpmnObj, callActivityId, [layers[0].id]))); + + // exclude if only selected elements should be exported and the importing call acitivity is not (indirectly) selected + const notInSelection = + options.exportSelectionOnly && + !(await isSelectedOrInsideSelected(bpmnObj, callActivityId, selectedElements || [])); + + if (inUnexportedLayer || notInSelection) { + delete importInfo[callActivityId]; + } + } + } + for (const { definitionId: importDefinitionId, version: importVersion } of Object.values( importInfo, )) { @@ -279,12 +345,30 @@ export async function prepareExport( for (const definitionId of Object.keys(exportData)) { // get the ids of all collapsed subprocesses so they can be used later during export if (options.subprocesses) { - for (const [version, { bpmn }] of Object.entries(exportData[definitionId].versions)) { - exportData[definitionId].versions[version].subprocesses = ( - await getCollapsedSubprocessInfos(bpmn) - ) - // the subprocess info is returned in the reversed order from what we want (we want from the outmost subprocess to the most nested) - .reverse(); + for (const { bpmn, selectedElements, layers } of Object.values( + exportData[definitionId].versions, + )) { + const bpmnObj = await toBpmnObject(bpmn); + + layers.push( + ...( + await asyncFilter(await getCollapsedSubprocessInfos(bpmn), async ({ id }) => { + return ( + // prevent a layer from being added a second time (might have already been added as the root layer of the export) + !layers.some((layer) => layer.id === id) && + // if the root layer is not the complete process filter out any subprocesses not nested under the root layer + (!layers.length || + !layers[0].id || + (await isSelectedOrInsideSelected(bpmnObj, id, [layers[0].id]))) && + // if the user selected the option to limit the exports to (indirectly) selected elements remove subprocesses that are not (indirectly) selected + (!options.exportSelectionOnly || + (await isSelectedOrInsideSelected(bpmnObj, id, selectedElements || []))) + ); + }) + ) + // the subprocess info is returned in the reversed order from what we want (we want from the outmost subprocess to the most nested) + .reverse(), + ); } } diff --git a/src/management-system-v2/lib/process-export/image-export.ts b/src/management-system-v2/lib/process-export/image-export.ts index cc5c3006e..486284abe 100644 --- a/src/management-system-v2/lib/process-export/image-export.ts +++ b/src/management-system-v2/lib/process-export/image-export.ts @@ -11,6 +11,7 @@ import { downloadFile, getSVGFromBPMN, getImageDimensions } from './util'; * @param generateBlobFromSvgString function that is used to get the final blob that will be exported from the svg string of the process * @param filetype the filetype to use for the export file * @param isImport if the data to be added is part of an imported process + * @param showOnlySelected if all elements that are not in the selected elements (in processData) should be hidden * @param zipFolder the folder to add the svg to (optional since we can export a single file directly as an svg which is decided before this function is called) * @param subprocessId if a specific collapsed subprocess should be added this is the id of the subprocess element * @param subprocessName the name of the collapsed subprocess to be added @@ -21,12 +22,17 @@ async function addImageFile( generateBlobFromSvgString: (svg: string) => Promise, filetype: string, isImport = false, + showOnlySelected?: boolean, zipFolder?: jsZip | null, subprocessId?: string, subprocessName?: string, ) { const versionData = processData.versions[version]; - const svg = await getSVGFromBPMN(versionData.bpmn!, subprocessId); + const svg = await getSVGFromBPMN( + versionData.bpmn!, + subprocessId, + showOnlySelected ? versionData.selectedElements : undefined, + ); const blob = await generateBlobFromSvgString(svg); @@ -65,6 +71,7 @@ async function addImageFile( * @param generateBlobFromSvgString function that is used to get the final blob that will be exported from the svg string of the process * @param filetype the filetype to use for the export file * @param isImport if the version is of an import + * @param showOnlySelected if all elements that are not in the selected elements (in processData) should be hidden * @param zipFolder the folder to add the svg to (optional since we can export a single file directly as an svg which is decided before this function is called) */ async function handleProcessVersionExport( @@ -74,27 +81,19 @@ async function handleProcessVersionExport( generateBlobFromSvgString: (svg: string) => Promise, filetype: string, isImport = false, + showOnlySelected?: boolean, zipFolder?: jsZip | null, ) { - // add the main process (version) file - await addImageFile( - processData, - version, - generateBlobFromSvgString, - filetype, - isImport, - zipFolder, - ); - const versionData = processData.versions[version]; - // add collapsed subprocesses as additional files - for (const { id: subprocessId, name: subprocessName } of versionData.subprocesses) { + // add the main process (version) file (layer with id === undefined) and collapsed subprocesses as additional files + for (const { id: subprocessId, name: subprocessName } of versionData.layers) { await addImageFile( processData, version, generateBlobFromSvgString, filetype, isImport, + showOnlySelected, zipFolder, subprocessId, subprocessName, @@ -112,6 +111,7 @@ async function handleProcessVersionExport( generateBlobFromSvgString, filetype, true, + showOnlySelected, zipFolder, ); } @@ -123,6 +123,7 @@ async function exportImage( processData: ProcessExportData, generateBlobFromSvgString: (svg: string) => Promise, filetype: string, + showOnlySelected?: boolean, zipFolder?: jsZip | null, ) { // only export the versions that were explicitly selected for export inside the folder for the given process @@ -138,6 +139,7 @@ async function exportImage( generateBlobFromSvgString, filetype, false, + showOnlySelected, zipFolder, ); } @@ -150,11 +152,13 @@ async function exportImage( * * @param processesData the data of all processes * @param processData the data of the complete process + * @param showOnlySelected if all elements that are not in the selected elements (in processData) should be hidden * @param zipFolder a zip folder the exported files should be added to in case of multi file export */ export async function svgExport( processesData: ProcessesExportData, processData: ProcessExportData, + showOnlySelected?: boolean, zipFolder?: jsZip | null, ) { await exportImage( @@ -166,6 +170,7 @@ export async function svgExport( }); }, 'svg', + showOnlySelected, zipFolder, ); } @@ -233,12 +238,14 @@ export async function getPNGFromSVG(svg: string, scaling = 1) { * @param processesData the data of all processes * @param processData the data of the complete process * @param scaling the scaling factor to use when transforming the process svg to a png + * @param showOnlySelected if all elements that are not in the selected elements (in processData) should be hidden * @param zipFolder a zip folder the exported files should be added to in case of multi file export */ export async function pngExport( processesData: ProcessesExportData, processData: ProcessExportData, scaling: number, + showOnlySelected?: boolean, zipFolder?: jsZip | null, ) { await exportImage( @@ -246,6 +253,7 @@ export async function pngExport( processData, (svg: string) => getPNGFromSVG(svg, scaling), 'png', + showOnlySelected, zipFolder, ); } diff --git a/src/management-system-v2/lib/process-export/index.ts b/src/management-system-v2/lib/process-export/index.ts index a26245ace..7a5d32664 100644 --- a/src/management-system-v2/lib/process-export/index.ts +++ b/src/management-system-v2/lib/process-export/index.ts @@ -74,7 +74,7 @@ export async function exportProcesses(options: ProcessExportOptions, processes: const hasMulitpleVersions = Object.keys(exportData[0].versions).length > 1; const hasArtefacts = !!exportData[0].userTasks.length || !!exportData[0].images.length; // this becomes relevant if there is only one version (otherwise hasMultipleVersions will lead to needsZip being true anyway) - const withSubprocesses = Object.values(exportData[0].versions)[0].subprocesses.length > 0; + const withSubprocesses = Object.values(exportData[0].versions)[0].layers.length > 1; needsZip = numProcesses > 1 || hasMulitpleVersions || hasArtefacts || withSubprocesses; } @@ -85,7 +85,14 @@ export async function exportProcesses(options: ProcessExportOptions, processes: if (options.type === 'pdf') { // handle imports inside the pdfExport function if (!processData.isImport) { - await pdfExport(exportData, processData, options.metaData, options.a4, zip); + await pdfExport( + exportData, + processData, + options.metaData, + options.a4, + options.exportSelectionOnly, + zip, + ); } } else { if (options.type === 'bpmn') { @@ -95,11 +102,17 @@ export async function exportProcesses(options: ProcessExportOptions, processes: // handle imports inside the svgExport function if (options.type === 'svg' && !processData.isImport) { const folder = zip?.folder(processData.definitionName); - await svgExport(exportData, processData, folder); + await svgExport(exportData, processData, options.exportSelectionOnly, folder); } if (options.type === 'png' && !processData.isImport) { const folder = zip?.folder(processData.definitionName); - await pngExport(exportData, processData, options.scaling, folder); + await pngExport( + exportData, + processData, + options.scaling, + options.exportSelectionOnly, + folder, + ); } } } diff --git a/src/management-system-v2/lib/process-export/pdf-export.ts b/src/management-system-v2/lib/process-export/pdf-export.ts index ab7d88b5d..8b71b24f5 100644 --- a/src/management-system-v2/lib/process-export/pdf-export.ts +++ b/src/management-system-v2/lib/process-export/pdf-export.ts @@ -22,6 +22,7 @@ const proceedLogo = fetch(`/proceed-labs-logo.svg`) * @param withMetaData if process information should be added as text to the process page * @param useA4 if the process page should have an A4 format (otherwise the size of the process is used to scale the page) * @param isImport if the data to be added is part of an imported process + * @param showOnlySelected if all elements that are not in the selected elements (in processData) should be hidden * @param subprocessId if a specific collapsed subprocess should be added this is the id of the subprocess element * @param subprocessName the name of the collapsed subprocess to be added */ @@ -32,6 +33,7 @@ async function addPDFPage( withMetaData: boolean, useA4: boolean, isImport = false, + showOnlySelected?: boolean, subprocessId?: string, subprocessName?: string, ) { @@ -68,7 +70,11 @@ async function addPDFPage( } // get the svg so we can display the process as a vector graphic inside the pdf - const processSVG = await getSVGFromBPMN(versionData.bpmn, subprocessId); + const processSVG = await getSVGFromBPMN( + versionData.bpmn, + subprocessId, + showOnlySelected ? versionData.selectedElements : undefined, + ); // register the image of the process pageBuilder.addVectorImage( processSVG, @@ -88,6 +94,7 @@ async function addPDFPage( * @param pdf the pdf to add a page to * @param withMetaData if process information should be added as text to the process page * @param forceA4 if the pdf pages should always have an A4 page format + * @param showOnlySelected if all elements that are not in the selected elements (in processData) should be hidden * @param isImport if the version is of an import */ async function handleProcessVersionPdfExport( @@ -97,14 +104,12 @@ async function handleProcessVersionPdfExport( pdf: jsPDF, withMetaData: boolean, forceA4: boolean, + showOnlySelected?: boolean, isImport = false, ) { - // add the main process (version) data - await addPDFPage(processData, version, pdf, withMetaData, forceA4, isImport); - const versionData = processData.versions[version]; - // add all collapsed subprocesses - for (const { id: subprocessId, name: subprocessName } of versionData.subprocesses) { + // add the main process (version) (layer with id === undefined) and all collapsed subprocesses + for (const { id: subprocessId, name: subprocessName } of versionData.layers) { await addPDFPage( processData, version, @@ -112,6 +117,7 @@ async function handleProcessVersionPdfExport( withMetaData, forceA4, isImport, + showOnlySelected, subprocessId, subprocessName, ); @@ -128,6 +134,7 @@ async function handleProcessVersionPdfExport( pdf, withMetaData, forceA4, + showOnlySelected, true, ); } @@ -141,6 +148,7 @@ async function handleProcessVersionPdfExport( * @param processData the data of the complete process to export * @param withMetaData if process information should be added as text to the process page * @param forceA4 if the pdf pages should always have an A4 page format + * @param showOnlySelected if all elements that are not in the selected elements (in processData) should be hidden * @param zip a zip archive this pdf should be added to in case multiple processes should be exported */ async function pdfExport( @@ -148,6 +156,7 @@ async function pdfExport( processData: ProcessExportData, withMetaData: boolean, forceA4: boolean, + showOnlySelected?: boolean, zip?: jsZip | null, ) { // create the pdf file for the process @@ -174,6 +183,7 @@ async function pdfExport( pdf, withMetaData, forceA4, + showOnlySelected, ); } diff --git a/src/management-system-v2/lib/process-export/util.ts b/src/management-system-v2/lib/process-export/util.ts index d0bdb7574..4a3b2e3f3 100644 --- a/src/management-system-v2/lib/process-export/util.ts +++ b/src/management-system-v2/lib/process-export/util.ts @@ -1,6 +1,11 @@ import { v4 } from 'uuid'; -import { toBpmnObject, toBpmnXml, getElementById } from '@proceed/bpmn-helper'; +import { is as bpmnIs } from 'bpmn-js/lib/util/ModelUtil'; +import type ElementRegistry from 'diagram-js/lib/core/ElementRegistry'; + +import { toBpmnObject, getElementById, getRootFromElement } from '@proceed/bpmn-helper'; +import { asyncFilter } from '../helpers/javascriptHelpers'; +import { getElementsByTagName } from '@proceed/bpmn-helper/src/util'; /** * Downloads the data onto the device of the user @@ -48,36 +53,6 @@ export async function getSVGFromBPMN( viewerElement.id = 'canvas_' + v4(); document.body.appendChild(viewerElement); - // if specific elements are selected for export make sure to filter out all other elements - if (flowElementsToExportIds.length) { - const bpmnObj: any = await toBpmnObject(bpmn!); - // remove connections where the source or target or both are not selected - flowElementsToExportIds = flowElementsToExportIds.filter((id) => { - const el = getElementById(bpmnObj, id) as any; - return ( - el && - (!el.sourceRef || flowElementsToExportIds.includes(el.sourceRef.id)) && - (!el.targetRef || flowElementsToExportIds.includes(el.targetRef.id)) - ); - }); - - if (flowElementsToExportIds.length) { - // find the correct plane (either the root process/collaboration or a subprocess) - const { plane } = bpmnObj.diagrams.find((el: any) => - subprocessId - ? // either find the subprocess plane - el.plane.bpmnElement.id === subprocessId - : // or the root process/collaboration plane - el.plane.bpmnElement.$type !== 'bpmn:SubProcess', - ); - // remove the visualisation of the elements that are not selected - plane.planeElement = plane.planeElement.filter((diEl: any) => - flowElementsToExportIds.some((id) => id === diEl.bpmnElement.id), - ); - bpmn = await toBpmnXml(bpmnObj); - } - } - //Create a viewer to transform the bpmn into an svg const viewer = new Viewer({ container: '#' + viewerElement.id }); await viewer.importXML(bpmn); @@ -91,6 +66,39 @@ export async function getSVGFromBPMN( canvas.zoom('fit-viewport', 'auto'); + const elementRegistry = viewer.get('elementRegistry') as ElementRegistry; + + // if specific elements are selected for export make sure to filter out all other elements + if (flowElementsToExportIds.length) { + // remove connections where the source or target or both are not selected + flowElementsToExportIds = flowElementsToExportIds.filter((id) => { + const el = elementRegistry.get(id); + return ( + el && + (!el.source || flowElementsToExportIds.includes(el.source.id)) && + (!el.target || flowElementsToExportIds.includes(el.target.id)) + ); + }); + if (flowElementsToExportIds.length) { + // hide all elements that are not directly selected and that are not indirectly selected due to a parent being selected + const allElements = elementRegistry.getAll(); + + const unselectedElements = await asyncFilter(allElements, async ({ businessObject }) => { + return ( + !businessObject || + !(await isSelectedOrInsideSelected( + getRootFromElement(businessObject), + businessObject.id, + flowElementsToExportIds, + )) + ); + }); + unselectedElements.forEach((el: any) => { + elementRegistry.getGraphics(el).style.setProperty('display', 'none'); + }); + } + } + const { svg } = await viewer.saveSVG(); document.body.removeChild(viewerElement); @@ -122,3 +130,37 @@ export function getImageDimensions(svg: string) { height, }; } + +/** + * Checks for a moddle element if it is selected or if an element it is nested in is selected + * + * @param bpmnOrObj the bpmn that should contain the element either as a string or as an object + * @param id the id of the moddle element + * @param selectedElementIds the ids of the elements that are considered selected + */ +export async function isSelectedOrInsideSelected( + bpmnOrObj: string | object, + id: string, + selectedElementIds: string[], +) { + const bpmnObj = typeof bpmnOrObj === 'string' ? await toBpmnObject(bpmnOrObj) : bpmnOrObj; + + let el = getElementById(bpmnObj, id) as any; + + // this handles all elements that are directly selected or insided a selected subprocess + while (el && !bpmnIs(el, 'bpmn:Process')) { + if (selectedElementIds.includes(el.id)) return true; + el = el.$parent; + } + + const participants = getElementsByTagName(bpmnObj, 'bpmn:Participant'); + + // this handles all elements that are children of a selected participant + return ( + !!el && + participants.some( + (participant) => + selectedElementIds.includes(participant.id) && participant.processRef?.id === el?.id, + ) + ); +} diff --git a/src/management-system-v2/lib/typescript-utils.ts b/src/management-system-v2/lib/typescript-utils.ts index c72f4cbf3..82c5e12d0 100644 --- a/src/management-system-v2/lib/typescript-utils.ts +++ b/src/management-system-v2/lib/typescript-utils.ts @@ -35,3 +35,8 @@ export type RemoveReadOnly = T extends Record -readonly [Key in keyof T]: RemoveReadOnly; } : T; + +/** + * When given an array type returns the type of an element inside the array + */ +export type ArrayEntryType = T extends Array ? EntryType : never;