From fc491d49884131bb3243a79855adafd47f76b004 Mon Sep 17 00:00:00 2001 From: jjoderis <58050428+jjoderis@users.noreply.github.com> Date: Mon, 9 Oct 2023 09:55:48 +0200 Subject: [PATCH] Ms2 Process Export (#85) * select box styling, table categories button added, functionality tba * select bar layout wip * added first version of a breadcrumb * styling wip * Breadcrumb version and process select * fuzzy search, dropdown for columns, deselect for multirow * add comment for fuzzy search * changed colour breadcrumb, added latest changes to modeler (jj), Menu (sider) updated * Adjusted Modeler height, added Preview-Viewer Co-authored-by: winniel24 Co-authored-by: jjoderis Co-authored-by: Lucas * Made previewer height adjustable * Added export of a single process as bpmn, svg or pdf from the process modeler * Added the process export modal to the processes view in addition to the process editor view * Ran prettier * Removed unnecessary code --------- Co-authored-by: winnie Co-authored-by: LaMaLein <76120220+LaMaLein@users.noreply.github.com> Co-authored-by: LaMaLein Co-authored-by: Kai Rohwer --- .../components/modeler-toolbar.tsx | 17 ++- .../components/process-export.tsx | 72 ++++++++++ .../components/process-list.tsx | 15 +- .../components/processes.tsx | 14 +- .../lib/process-export.ts | 131 ++++++++++++++++++ .../lib/process-queries.ts | 76 +++++++--- src/management-system-v2/package.json | 3 + yarn.lock | 27 +++- 8 files changed, 328 insertions(+), 27 deletions(-) create mode 100644 src/management-system-v2/components/process-export.tsx create mode 100644 src/management-system-v2/lib/process-export.ts diff --git a/src/management-system-v2/components/modeler-toolbar.tsx b/src/management-system-v2/components/modeler-toolbar.tsx index a12f2a5a6..5a789d809 100644 --- a/src/management-system-v2/components/modeler-toolbar.tsx +++ b/src/management-system-v2/components/modeler-toolbar.tsx @@ -21,6 +21,9 @@ import PropertiesPanel from './properties-panel'; import useModelerStateStore from '@/lib/use-modeler-state-store'; import { useParams } from 'next/navigation'; + +import ProcessExportModal from './process-export'; + import { createNewProcessVersion } from '@/lib/helpers'; import VersionCreationButton from './version-creation-button'; import { useGetAsset } from '@/lib/fetch-data'; @@ -34,6 +37,7 @@ const ModelerToolbar: React.FC = ({ onOpenXmlEditor }) => { const svgShare = ; const [showPropertiesPanel, setShowPropertiesPanel] = useState(false); + const [showProcessExportModal, setShowProcessExportModal] = useState(false); const modeler = useModelerStateStore((state) => state.modeler); const selectedElementId = useModelerStateStore((state) => state.selectedElementId); @@ -78,6 +82,12 @@ const ModelerToolbar: React.FC = ({ onOpenXmlEditor }) => { setShowPropertiesPanel(!showPropertiesPanel); }; + const handleProcessExportModalToggle = async () => { + setShowProcessExportModal(!showProcessExportModal); + }; + + const selectedVersion = useModelerStateStore((state) => state.selectedVersion); + return ( <> @@ -96,7 +106,7 @@ const ModelerToolbar: React.FC = ({ onOpenXmlEditor }) => { - + @@ -129,6 +139,11 @@ const ModelerToolbar: React.FC = ({ onOpenXmlEditor }) => { {/* {showPropertiesPanel && selectedElement && ( )} */} + setShowProcessExportModal(false)} + processVersion={selectedVersion || undefined} + /> ); }; diff --git a/src/management-system-v2/components/process-export.tsx b/src/management-system-v2/components/process-export.tsx new file mode 100644 index 000000000..58435af5b --- /dev/null +++ b/src/management-system-v2/components/process-export.tsx @@ -0,0 +1,72 @@ +import React, { useState } from 'react'; + +import { Modal, Checkbox } from 'antd'; +import type { CheckboxValueType } from 'antd/es/checkbox/Group'; + +import { exportBpmn, exportPDF, exportSVG } from '@/lib/process-export'; + +const exportTypeOptions = [ + { label: 'BPMN', value: 'bpmn' }, + { label: 'PDF', value: 'pdf' }, + { label: 'SVG', value: 'svg' }, +]; + +type ProcessExportModalProps = { + processId?: string; // the id of the process to export; also used to decide if the modal should be opened + onClose: () => void; + processVersion?: number | string; +}; + +const ProcessExportModal: React.FC = ({ + processId, + onClose, + processVersion, +}) => { + const [selectedTypes, setSelectedTypes] = useState([]); + + const handleTypeSelectionChange = (checkedValues: CheckboxValueType[]) => { + // allow selection of exactly one element + if (!checkedValues.length) return; + setSelectedTypes(checkedValues.filter((el) => !selectedTypes.includes(el))); + }; + + const handleProcessExport = async () => { + switch (selectedTypes[0]) { + case 'bpmn': + await exportBpmn(processId!, processVersion); + break; + case 'pdf': + await exportPDF(processId!, processVersion); + break; + case 'svg': + await exportSVG(processId!, processVersion); + break; + default: + throw 'Unexpected value for process export!'; + } + + onClose(); + }; + + return ( + <> + + + + + ); +}; + +export default ProcessExportModal; diff --git a/src/management-system-v2/components/process-list.tsx b/src/management-system-v2/components/process-list.tsx index 20c449ab6..47618678a 100644 --- a/src/management-system-v2/components/process-list.tsx +++ b/src/management-system-v2/components/process-list.tsx @@ -34,6 +34,7 @@ type ProcessListProps = PropsWithChildren<{ selection: Key[]; setSelection: Dispatch>; isLoading?: boolean; + onExportProcess: Dispatch>; }>; const ColumnHeader = [ @@ -64,7 +65,13 @@ const numberOfRows = typeof window !== 'undefined' ? Math.floor((window?.innerHeight - 340) / 47) : 10; console.log(numberOfRows); -const ProcessList: FC = ({ data, selection, setSelection, isLoading }) => { +const ProcessList: FC = ({ + data, + selection, + setSelection, + isLoading, + onExportProcess, +}) => { const router = useRouter(); const [previewerOpen, setPreviewerOpen] = useState(false); @@ -104,7 +111,11 @@ const ProcessList: FC = ({ data, selection, setSelection, isLo - + { + onExportProcess(record.definitionId); + }} + /> diff --git a/src/management-system-v2/components/processes.tsx b/src/management-system-v2/components/processes.tsx index f7616d748..5085edeee 100644 --- a/src/management-system-v2/components/processes.tsx +++ b/src/management-system-v2/components/processes.tsx @@ -18,6 +18,7 @@ import IconView from './process-icon-list'; import ProcessList from './process-list'; import { Preferences, getPreferences, addUserPreference } from '@/lib/utils'; import MetaData from './process-info-card'; +import ProcessExportModal from './process-export'; import Bar from './bar'; type Processes = ApiData<'/process', 'get'>; @@ -58,6 +59,8 @@ const Processes: FC = () => { const [iconView, setIconView] = useState(prefs['icon-view-in-process-list']); + const [exportProcessId, setExportProcessId] = useState(); + const actionBar = ( <> {/* @@ -67,7 +70,11 @@ const Processes: FC = () => { - + { + setExportProcessId(selectedRowKeys[0].toString()); + }} + /> @@ -173,12 +180,17 @@ const Processes: FC = () => { selection={selectedRowKeys} setSelection={setSelectedRowKeys} isLoading={isLoading} + onExportProcess={setExportProcessId} /> )} {/* Meta Data Panel */} + setExportProcessId(undefined)} + /> ); }; diff --git a/src/management-system-v2/lib/process-export.ts b/src/management-system-v2/lib/process-export.ts new file mode 100644 index 000000000..b7c096662 --- /dev/null +++ b/src/management-system-v2/lib/process-export.ts @@ -0,0 +1,131 @@ +import { v4 } from 'uuid'; +import { jsPDF } from 'jspdf'; +import 'svg2pdf.js'; + +import { fetchProcessVersionBpmn, fetchProcess } from './process-queries'; + +async function getProcessData(processId: string, processVersion?: string | number) { + // TODO: we use the data for the name but it is maybe better to get the name from the bpmn since it might be different in versioned bpmn + const data = await fetchProcess(processId); + + if (!data) { + throw new Error( + `Failed to get process info (definitionId: ${processId}) during process export`, + ); + } + + if (processVersion) { + const versionBpmn = await fetchProcessVersionBpmn(processId, processVersion); + if (!versionBpmn) { + throw new Error( + `Failed to get the bpmn for a version (${processVersion}) of a process (definitionId: ${processId})`, + ); + } + + data.bpmn = versionBpmn; + } + + return data; +} + +export async function exportBpmn(processId: string, processVersion?: string | number) { + const process = await getProcessData(processId, processVersion); + + const bpmnBlob = new Blob([process.bpmn!], { type: 'application/xml' }); + + exportFile(`${process.definitionName}.bpmn`, bpmnBlob); +} + +async function getSVGFromBPMN(bpmn: string) { + const Viewer = (await import('bpmn-js/lib/Viewer')).default; + + //Creating temporary element for BPMN Viewer + const viewerElement = document.createElement('div'); + + //Assiging process id to temp element and append to DOM + viewerElement.id = 'canvas_' + v4(); + document.body.appendChild(viewerElement); + + //Create a viewer to transform the bpmn into an svg + const viewer = new Viewer({ container: '#' + viewerElement.id }); + await viewer.importXML(bpmn); + const { svg } = await viewer.saveSVG(); + + return svg; +} + +export async function exportSVG(processId: string, processVersion?: string | number) { + const process = await getProcessData(processId, processVersion); + + const svg = await getSVGFromBPMN(process.bpmn!); + + const svgBlob = new Blob([svg], { + type: 'image/svg+xml', + }); + + exportFile(`${process.definitionName}.svg`, svgBlob); +} + +export async function exportPDF(processId: string, processVersion?: string | number) { + const process = await getProcessData(processId, processVersion); + + const svg = await getSVGFromBPMN(process.bpmn!); + + const parser = new DOMParser(); + const svgDOM = parser.parseFromString(svg, 'image/svg+xml'); + + const doc = new jsPDF({ + unit: 'pt', // needed due to a bug in jsPDF: https://github.com/yWorks/svg2pdf.js/issues/245#issuecomment-1671624250 + format: 'a4', + orientation: 'landscape', + }); + + doc.deletePage(1); + + // get image dimensions + let svgWidth = parseFloat(svg.split('width="')[1].split('"')[0]); + let svgHeight = 20 + parseFloat(svg.split('height="')[1].split('"')[0]); + + // adding a new page, second parameter orientation: p - portrait, l - landscape + doc.addPage([svgWidth, svgHeight], svgHeight > svgWidth ? 'p' : 'l'); + + //Getting PDF Documents width and height + const pageWidth = doc.internal.pageSize.getWidth() - 10; + const pageHeight = doc.internal.pageSize.getHeight() - 10; + + //Setting pdf font size + doc.setFontSize(15); + + //Adding Header to the Pdf + // TODO: make sure that the text fits both in landscape as well as in portrait mode + doc.text(`Process: ${process.definitionName} \n`, 10, 15); + + await doc.svg(svgDOM.children[0], { + x: 0, + y: 10, + width: pageWidth, + height: pageHeight, + }); + + await doc.save(`${process.definitionName}.pdf`); +} + +function exportFile(processName: string, data: Blob) { + const objectURL = URL.createObjectURL(data); + + // Creating Anchor Element to trigger download feature + const aLink = document.createElement('a'); + + // Setting anchor tag properties + aLink.style.display = 'none'; + aLink.download = processName; + aLink.href = objectURL; + + // Setting anchor tag to DOM + document.body.appendChild(aLink); + aLink.click(); + document.body.removeChild(aLink); + + // Release Object URL, so browser dont keep reference + URL.revokeObjectURL(objectURL); +} diff --git a/src/management-system-v2/lib/process-queries.ts b/src/management-system-v2/lib/process-queries.ts index 6b970d9cc..ec66a7cb0 100644 --- a/src/management-system-v2/lib/process-queries.ts +++ b/src/management-system-v2/lib/process-queries.ts @@ -5,29 +5,61 @@ export const useProcessBpmn = (definitionId: string, version?: number | string | return useQuery({ queryKey: ['process', definitionId, 'bpmn', version], queryFn: async () => { - if (version) { - const { data } = await get('/process/{definitionId}/versions/{version}', { - params: { - path: { - definitionId, - version: typeof version === 'number' ? version.toString() : version, - }, - }, - parseAs: 'text', - }); - - return data; - } else { - const { data } = await get('/process/{definitionId}', { - params: { - path: { - definitionId, - }, - }, - }); + return await fetchProcessVersionBpmn(definitionId, version); + }, + }); +}; - return data?.bpmn; - } +/** + * Fetches the meta data for a specific process from the backend + * + * @param definitionId the id of the process to fetch + * @returns the process meta data + */ +export const fetchProcess = async (definitionId: string) => { + const { data } = await get('/process/{definitionId}', { + params: { + path: { + definitionId, + }, }, }); + + return data; +}; + +/** + * Fetches the bpmn for a process which is either the current editable bpmn of the process or the bpmn of a specific version + * + * @param definitionId the id of the process to fetch + * @param version the version for which the bpmn should be fetched (will fetch the editable bpmn if no version is specified) + * @returns the process (version) bpmn + */ +export const fetchProcessVersionBpmn = async ( + definitionId: string, + version?: number | string | null, +) => { + if (version) { + const { data } = await get('/process/{definitionId}/versions/{version}', { + params: { + path: { + definitionId, + version: typeof version === 'number' ? version.toString() : version, + }, + }, + parseAs: 'text', + }); + + return data; + } else { + const { data } = await get('/process/{definitionId}', { + params: { + path: { + definitionId, + }, + }, + }); + + return data?.bpmn; + } }; diff --git a/src/management-system-v2/package.json b/src/management-system-v2/package.json index b8a046eee..578702ab6 100644 --- a/src/management-system-v2/package.json +++ b/src/management-system-v2/package.json @@ -20,6 +20,9 @@ "singleQuote": true }, "dependencies": { + "jspdf": "^2.5.1", + "svg2pdf.js": "^2.2.2", + "uuid": "^9.0.0", "@monaco-editor/react": "4.5.2", "@tanstack/react-query": "4.35.7", "antd": "5.9.4", diff --git a/yarn.lock b/yarn.lock index 2fe46d594..23f9f3d1a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9933,6 +9933,11 @@ follow-redirects@^1.0.0: resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.15.3.tgz#fe2f3ef2690afce7e82ed0b44db08165b207123a" integrity sha512-1VzOtuEM8pC9SFU1E+8KfTjZyMztRsgEfwQl44z8A25uy13jSzTj6dyK2Df52iV0vgHCfBwLhDWevLn95w5v6Q== +font-family-papandreou@^0.2.0-patch1: + version "0.2.0-patch2" + resolved "https://registry.yarnpkg.com/font-family-papandreou/-/font-family-papandreou-0.2.0-patch2.tgz#c75b659e96ffbc7ab2af651cf7b4910b334e8dd2" + integrity sha512-l/YiRdBSH/eWv6OF3sLGkwErL+n0MqCICi9mppTZBOCL5vixWGDqCYvRcuxB2h7RGCTzaTKOHT2caHvCXQPRlw== + for-each@^0.3.3: version "0.3.3" resolved "https://registry.yarnpkg.com/for-each/-/for-each-0.3.3.tgz#69b447e88a0a5d32c3e7084f3f1710034b21376e" @@ -13402,7 +13407,7 @@ jspdf-yworks@^2.1.1: omggif "1.0.9" stackblur-canvas "2.2.0" -jspdf@^2.3.0: +jspdf@^2.3.0, jspdf@^2.5.1: version "2.5.1" resolved "https://registry.yarnpkg.com/jspdf/-/jspdf-2.5.1.tgz#00c85250abf5447a05f3b32ab9935ab4a56592cc" integrity sha512-hXObxz7ZqoyhxET78+XR34Xu2qFGrJJ2I2bE5w4SM8eFaFEkW2xcGRVUss360fYelwRSid/jT078kbNvmoW0QA== @@ -18704,6 +18709,11 @@ spdy@^4.0.2: select-hose "^2.0.0" spdy-transport "^3.0.0" +specificity@^0.4.1: + version "0.4.1" + resolved "https://registry.yarnpkg.com/specificity/-/specificity-0.4.1.tgz#aab5e645012db08ba182e151165738d00887b019" + integrity sha512-1klA3Gi5PD1Wv9Q0wUoOQN1IWAuPu0D1U03ThXTr0cJ20+/iq2tHSDnK7Kk/0LXJ1ztUB2/1Os0wKmfyNgUQfg== + spectrum-colorpicker@^1.8.0: version "1.8.1" resolved "https://registry.yarnpkg.com/spectrum-colorpicker/-/spectrum-colorpicker-1.8.1.tgz#34d3038dcfd0accb89597335b50a663160b170af" @@ -19271,6 +19281,16 @@ svg2pdf.js@^1.5.0: dependencies: jspdf-yworks "^2.1.1" +svg2pdf.js@^2.2.2: + version "2.2.2" + resolved "https://registry.yarnpkg.com/svg2pdf.js/-/svg2pdf.js-2.2.2.tgz#08bef6fb17fadbd96d4eed34e05a9d41740a9735" + integrity sha512-uhnLyeOCLzgvkjmck08kRaMxA3qdXxHcTVcrk9aZiVwzpnJeixdFWqTAtGgKhCpCF5jSsoBOuC4CY4KgdrB7WA== + dependencies: + cssesc "^3.0.0" + font-family-papandreou "^0.2.0-patch1" + specificity "^0.4.1" + svgpath "^2.3.0" + svgo@^1.0.0: version "1.3.2" resolved "https://registry.yarnpkg.com/svgo/-/svgo-1.3.2.tgz#b6dc511c063346c9e415b81e43401145b96d4167" @@ -19290,6 +19310,11 @@ svgo@^1.0.0: unquote "~1.1.1" util.promisify "~1.0.0" +svgpath@^2.3.0: + version "2.6.0" + resolved "https://registry.yarnpkg.com/svgpath/-/svgpath-2.6.0.tgz#5b160ef3d742b7dfd2d721bf90588d3450d7a90d" + integrity sha512-OIWR6bKzXvdXYyO4DK/UWa1VA1JeKq8E+0ug2DG98Y/vOmMpfZNj+TIG988HjfYSqtcy/hFOtZq/n/j5GSESNg== + swap-case@^1.1.0: version "1.1.2" resolved "https://registry.yarnpkg.com/swap-case/-/swap-case-1.1.2.tgz#c39203a4587385fad3c850a0bd1bcafa081974e3"