From d27e08dc20332d8813b08867b51ce838542777c0 Mon Sep 17 00:00:00 2001 From: Maarten van Toor Date: Thu, 30 Jul 2026 15:53:53 +0200 Subject: [PATCH 1/5] Include process links when exporting and importing a system process (#481) A process that is not linked to a case definition can now be exported together with its process links and imported on another environment, so changes tested on one environment no longer have to be reconnected by hand elsewhere. During the import, plugin links can be pointed at the plugin configurations of the target environment, and a summary reports which referenced forms, form flows, decision tables and called sub-processes are missing here. Those are exported and imported separately, because they can be shared with other processes. Process links supplied through config/global are now leading: a link that is not in the file is removed. Importing over a system process that may not be updated is refused, and importing over an existing process asks for confirmation first, like uploading a single bpmn file already did. That plain export now names the file after the process instead of diagram.bpmn. --- .../ExportAutoConfiguration.kt | 8 + .../GlobalProcessDefinitionExporter.kt | 68 ++++ .../GlobalProcessDefinitionExportRequest.kt | 27 ++ .../mapper/FormFlowProcessLinkMapper.kt | 22 ++ .../form/mapper/FormProcessLinkMapper.kt | 20 ++ .../form/mapper/FormProcessLinkMapperTest.kt | 62 ++++ .../com/ritense/importer/ImportService.kt | 14 +- .../ritense/importer/ValtimoImportService.kt | 26 +- .../ProcessLinkAutoConfiguration.kt | 45 ++- .../exporter/GlobalProcessLinkExporter.kt | 76 ++++ .../importer/GlobalProcessLinkImporter.kt | 49 ++- .../importer/ProcessLinkImporter.kt | 2 +- .../processlink/mapper/ProcessLinkMapper.kt | 18 +- .../ProcessLinkHttpSecurityConfigurer.kt | 18 + .../ProcessDefinitionImportPreviewService.kt | 245 +++++++++++++ .../web/rest/ProcessLinkResource.kt | 85 ++++- .../web/rest/dto/MissingReferenceDto.kt | 49 +++ ...ocessDefinitionImportPreviewResponseDto.kt | 44 +++ .../dto/ProcessDefinitionImportResponseDto.kt | 22 ++ .../GlobalProcessLinkExporterIntTest.kt | 106 ++++++ .../exporter/GlobalProcessLinkExporterTest.kt | 131 +++++++ ...GlobalProcessDefinitionRoundTripIntTest.kt | 96 ++++++ .../GlobalProcessLinkImporterIntTest.kt | 126 +++++++ .../importer/GlobalProcessLinkImporterTest.kt | 170 +++++++++ ...ssDefinitionImportPreviewServiceIntTest.kt | 177 ++++++++++ ...ocessDefinitionImportPreviewServiceTest.kt | 325 ++++++++++++++++++ .../web/rest/ProcessLinkResourceTest.kt | 115 ++++++- .../release-notes/13.x.x/13.40.0/README.md | 46 ++- ...lugin-configuration-mapping.component.html | 82 +++++ ...lugin-configuration-mapping.component.scss | 72 ++++ ...in-configuration-mapping.component.spec.ts | 142 ++++++++ .../plugin-configuration-mapping.component.ts | 232 +++++++++++++ .../projects/valtimo/plugin/src/public-api.ts | 2 + .../process-management-builder.component.html | 8 + .../process-management-builder.component.ts | 55 ++- .../process-management-upload.component.html | 149 ++++++-- .../process-management-upload.component.scss | 14 + ...rocess-management-upload.component.spec.ts | 263 ++++++++++++++ .../process-management-upload.component.ts | 217 +++++++++++- .../constants/process-management.test-ids.ts | 1 + .../src/lib/models/index.ts | 1 + .../models/process-definition-import.model.ts | 61 ++++ .../services/process-management.service.ts | 41 ++- .../valtimo/shared/assets/core/en.json | 45 ++- .../valtimo/shared/assets/core/nl.json | 45 ++- 45 files changed, 3545 insertions(+), 77 deletions(-) create mode 100644 backend/core/src/main/kotlin/com/ritense/valtimo/exporter/GlobalProcessDefinitionExporter.kt create mode 100644 backend/exporter/src/main/kotlin/com/ritense/exporter/request/GlobalProcessDefinitionExportRequest.kt create mode 100644 backend/process-link/src/main/kotlin/com/ritense/processlink/exporter/GlobalProcessLinkExporter.kt create mode 100644 backend/process-link/src/main/kotlin/com/ritense/processlink/service/ProcessDefinitionImportPreviewService.kt create mode 100644 backend/process-link/src/main/kotlin/com/ritense/processlink/web/rest/dto/MissingReferenceDto.kt create mode 100644 backend/process-link/src/main/kotlin/com/ritense/processlink/web/rest/dto/ProcessDefinitionImportPreviewResponseDto.kt create mode 100644 backend/process-link/src/main/kotlin/com/ritense/processlink/web/rest/dto/ProcessDefinitionImportResponseDto.kt create mode 100644 backend/process-link/src/test/kotlin/com/ritense/processlink/exporter/GlobalProcessLinkExporterIntTest.kt create mode 100644 backend/process-link/src/test/kotlin/com/ritense/processlink/exporter/GlobalProcessLinkExporterTest.kt create mode 100644 backend/process-link/src/test/kotlin/com/ritense/processlink/importer/GlobalProcessDefinitionRoundTripIntTest.kt create mode 100644 backend/process-link/src/test/kotlin/com/ritense/processlink/importer/GlobalProcessLinkImporterIntTest.kt create mode 100644 backend/process-link/src/test/kotlin/com/ritense/processlink/importer/GlobalProcessLinkImporterTest.kt create mode 100644 backend/process-link/src/test/kotlin/com/ritense/processlink/service/ProcessDefinitionImportPreviewServiceIntTest.kt create mode 100644 backend/process-link/src/test/kotlin/com/ritense/processlink/service/ProcessDefinitionImportPreviewServiceTest.kt create mode 100644 frontend/projects/valtimo/plugin/src/lib/components/plugin-configuration-mapping/plugin-configuration-mapping.component.html create mode 100644 frontend/projects/valtimo/plugin/src/lib/components/plugin-configuration-mapping/plugin-configuration-mapping.component.scss create mode 100644 frontend/projects/valtimo/plugin/src/lib/components/plugin-configuration-mapping/plugin-configuration-mapping.component.spec.ts create mode 100644 frontend/projects/valtimo/plugin/src/lib/components/plugin-configuration-mapping/plugin-configuration-mapping.component.ts create mode 100644 frontend/projects/valtimo/process-management/src/lib/components/process-management-upload/process-management-upload.component.spec.ts create mode 100644 frontend/projects/valtimo/process-management/src/lib/models/process-definition-import.model.ts diff --git a/backend/core/src/main/kotlin/com/ritense/valtimo/autoconfiguration/ExportAutoConfiguration.kt b/backend/core/src/main/kotlin/com/ritense/valtimo/autoconfiguration/ExportAutoConfiguration.kt index 63c55accd2..17c7915e51 100644 --- a/backend/core/src/main/kotlin/com/ritense/valtimo/autoconfiguration/ExportAutoConfiguration.kt +++ b/backend/core/src/main/kotlin/com/ritense/valtimo/autoconfiguration/ExportAutoConfiguration.kt @@ -19,6 +19,7 @@ package com.ritense.valtimo.autoconfiguration import com.ritense.valtimo.operaton.service.OperatonRepositoryService import com.ritense.valtimo.exporter.DecisionDefinitionExporter +import com.ritense.valtimo.exporter.GlobalProcessDefinitionExporter import com.ritense.valtimo.exporter.ProcessDefinitionExporter import org.operaton.bpm.engine.RepositoryService import org.springframework.boot.autoconfigure.AutoConfiguration @@ -35,6 +36,13 @@ class ExportAutoConfiguration { repositoryService: RepositoryService ) = ProcessDefinitionExporter(operatonRepositoryService, repositoryService) + @Bean + @ConditionalOnMissingBean(GlobalProcessDefinitionExporter::class) + fun globalProcessDefinitionExporter( + operatonRepositoryService: OperatonRepositoryService, + repositoryService: RepositoryService + ) = GlobalProcessDefinitionExporter(operatonRepositoryService, repositoryService) + @Bean @ConditionalOnMissingBean(DecisionDefinitionExporter::class) fun decisionDefinitionExporter( diff --git a/backend/core/src/main/kotlin/com/ritense/valtimo/exporter/GlobalProcessDefinitionExporter.kt b/backend/core/src/main/kotlin/com/ritense/valtimo/exporter/GlobalProcessDefinitionExporter.kt new file mode 100644 index 0000000000..8625d546bf --- /dev/null +++ b/backend/core/src/main/kotlin/com/ritense/valtimo/exporter/GlobalProcessDefinitionExporter.kt @@ -0,0 +1,68 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.ritense.valtimo.exporter + +import com.ritense.exporter.ExportFile +import com.ritense.exporter.ExportResult +import com.ritense.exporter.Exporter +import com.ritense.exporter.request.GlobalProcessDefinitionExportRequest +import com.ritense.valtimo.operaton.service.OperatonRepositoryService +import org.operaton.bpm.engine.RepositoryService +import org.operaton.bpm.model.bpmn.Bpmn +import java.io.ByteArrayOutputStream + +/** + * Exports the BPMN of a process definition that is not part of a case definition. + * + * Unlike [ProcessDefinitionExporter] this exporter does not create related export requests for + * called sub-processes or referenced decision definitions. Those can be shared with other + * processes and are exported and imported separately. + */ +class GlobalProcessDefinitionExporter( + private val operatonRepositoryService: OperatonRepositoryService, + private val repositoryService: RepositoryService, +) : Exporter { + + override fun supports(): Class = + GlobalProcessDefinitionExportRequest::class.java + + override fun export(request: GlobalProcessDefinitionExportRequest): ExportResult { + val processDefinition = requireNotNull( + operatonRepositoryService.findProcessDefinitionById(request.processDefinitionId) + ) { + "Process definition with id '${request.processDefinitionId}' could not be found!" + } + + val bpmnModelInstance = repositoryService.getProcessModel(processDefinition.id).use { inputStream -> + Bpmn.readModelFromStream(inputStream) + } + + val exportFile = ByteArrayOutputStream().use { + Bpmn.writeModelToStream(it, bpmnModelInstance) + ExportFile( + PATH.format(processDefinition.key), + it.toByteArray() + ) + } + + return ExportResult(exportFile) + } + + companion object { + private const val PATH = "config/global/bpmn/%s.bpmn" + } +} diff --git a/backend/exporter/src/main/kotlin/com/ritense/exporter/request/GlobalProcessDefinitionExportRequest.kt b/backend/exporter/src/main/kotlin/com/ritense/exporter/request/GlobalProcessDefinitionExportRequest.kt new file mode 100644 index 0000000000..e928a76b6a --- /dev/null +++ b/backend/exporter/src/main/kotlin/com/ritense/exporter/request/GlobalProcessDefinitionExportRequest.kt @@ -0,0 +1,27 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.ritense.exporter.request + +/** + * Export request for a process definition that is not linked to a case definition or building block. + * Exports into the `config/global` folder structure, so it can be imported on another environment + * without a case definition being involved. + */ +data class GlobalProcessDefinitionExportRequest( + val processDefinitionId: String, + override val required: Boolean = true, +) : ExportRequest(required) diff --git a/backend/form-flow/src/main/kotlin/com/ritense/formflow/mapper/FormFlowProcessLinkMapper.kt b/backend/form-flow/src/main/kotlin/com/ritense/formflow/mapper/FormFlowProcessLinkMapper.kt index f558c9eff6..42f5f5af59 100644 --- a/backend/form-flow/src/main/kotlin/com/ritense/formflow/mapper/FormFlowProcessLinkMapper.kt +++ b/backend/form-flow/src/main/kotlin/com/ritense/formflow/mapper/FormFlowProcessLinkMapper.kt @@ -32,6 +32,8 @@ import com.ritense.logging.withLoggingContext import com.ritense.processlink.autodeployment.ProcessLinkDeployDto import com.ritense.processlink.domain.ProcessLink import com.ritense.processlink.mapper.ProcessLinkMapper +import com.ritense.processlink.web.rest.dto.MissingReferenceDto +import com.ritense.processlink.web.rest.dto.MissingReferenceType import com.ritense.processlink.web.rest.dto.ProcessLinkCreateRequestDto import com.ritense.processlink.web.rest.dto.ProcessLinkExportResponseDto import com.ritense.processlink.web.rest.dto.ProcessLinkResponseDto @@ -193,6 +195,26 @@ class FormFlowProcessLinkMapper( override fun getImporterType() = "formflow" + override fun getMissingReference(deployDto: ProcessLinkDeployDto, blueprintId: BlueprintId?): MissingReferenceDto? { + deployDto as FormFlowProcessLinkDeployDto + val definition = when (blueprintId) { + is CaseDefinitionId -> formFlowService.findDefinitionOrNull(deployDto.formFlowDefinitionKey, blueprintId) + is BuildingBlockDefinitionId -> formFlowService.findDefinitionOrNull(deployDto.formFlowDefinitionKey, blueprintId) + // A form flow definition only exists for a case or building block, so a form flow process + // link can never be created without one. See assertFormFlowDefinitionExists. + else -> null + } + return if (definition != null) { + null + } else { + MissingReferenceDto( + type = MissingReferenceType.FORM_FLOW, + reference = deployDto.formFlowDefinitionKey, + activityId = deployDto.activityId, + ) + } + } + companion object { const val PROCESS_LINK_TYPE_FORM_FLOW = "form-flow" } diff --git a/backend/form/src/main/kotlin/com/ritense/form/mapper/FormProcessLinkMapper.kt b/backend/form/src/main/kotlin/com/ritense/form/mapper/FormProcessLinkMapper.kt index 1d630d1d79..f59ddbdc86 100644 --- a/backend/form/src/main/kotlin/com/ritense/form/mapper/FormProcessLinkMapper.kt +++ b/backend/form/src/main/kotlin/com/ritense/form/mapper/FormProcessLinkMapper.kt @@ -32,6 +32,8 @@ import com.ritense.form.web.rest.dto.FormProcessLinkUpdateRequestDto import com.ritense.processlink.autodeployment.ProcessLinkDeployDto import com.ritense.processlink.domain.ProcessLink import com.ritense.processlink.mapper.ProcessLinkMapper +import com.ritense.processlink.web.rest.dto.MissingReferenceDto +import com.ritense.processlink.web.rest.dto.MissingReferenceType import com.ritense.processlink.web.rest.dto.ProcessLinkCreateRequestDto import com.ritense.processlink.web.rest.dto.ProcessLinkExportResponseDto import com.ritense.processlink.web.rest.dto.ProcessLinkResponseDto @@ -171,6 +173,24 @@ class FormProcessLinkMapper( override fun getImporterType() = "form" + override fun getMissingReference(deployDto: ProcessLinkDeployDto, blueprintId: BlueprintId?): MissingReferenceDto? { + deployDto as FormProcessLinkDeployDto + val formDefinition = if (blueprintId != null) { + formDefinitionService.getFormDefinitionByName(deployDto.formDefinitionName, blueprintId) + } else { + formDefinitionService.getFormDefinitionByName(deployDto.formDefinitionName) + } + return if (formDefinition.isPresent) { + null + } else { + MissingReferenceDto( + type = MissingReferenceType.FORM, + reference = deployDto.formDefinitionName, + activityId = deployDto.activityId, + ) + } + } + private fun resolveFormDefinition(formName: String, blueprintId: BlueprintId?): FormIoFormDefinition { val result = if (blueprintId != null) { formDefinitionService.getFormDefinitionByName(formName, blueprintId) diff --git a/backend/form/src/test/kotlin/com/ritense/form/mapper/FormProcessLinkMapperTest.kt b/backend/form/src/test/kotlin/com/ritense/form/mapper/FormProcessLinkMapperTest.kt index cd4b69ee3c..9fa47415d5 100644 --- a/backend/form/src/test/kotlin/com/ritense/form/mapper/FormProcessLinkMapperTest.kt +++ b/backend/form/src/test/kotlin/com/ritense/form/mapper/FormProcessLinkMapperTest.kt @@ -28,6 +28,7 @@ import com.ritense.form.web.rest.dto.FormProcessLinkCreateRequestDto import com.ritense.form.web.rest.dto.FormProcessLinkResponseDto import com.ritense.form.web.rest.dto.FormProcessLinkUpdateRequestDto import com.ritense.processlink.domain.ActivityTypeWithEventName.USER_TASK_CREATE +import com.ritense.processlink.web.rest.dto.MissingReferenceType import com.ritense.valtimo.contract.BlueprintId import com.ritense.valtimo.contract.buildingblock.BuildingBlockDefinitionId import com.ritense.valtimo.contract.case_.CaseDefinitionId @@ -355,6 +356,67 @@ internal class FormProcessLinkMapperTest { assertEquals(formDefinitionId, result.formDefinitionId) } + @Test + fun `should report a missing reference when the form does not exist globally`() { + val deployDto = FormProcessLinkDeployDto( + processDefinitionId = "process-def:1:123", + activityId = "userTask1", + activityType = USER_TASK_CREATE, + formDefinitionName = "my-form", + subtitles = null + ) + whenever(formDefinitionService.getFormDefinitionByName("my-form")).thenReturn(Optional.empty()) + + val missingReference = formProcessLinkMapper.getMissingReference(deployDto, null) + + assertThat(missingReference).isNotNull + assertThat(missingReference!!.type).isEqualTo(MissingReferenceType.FORM) + assertThat(missingReference.reference).isEqualTo("my-form") + assertThat(missingReference.activityId).isEqualTo("userTask1") + assertThat(missingReference.blocksImport).isTrue() + } + + @Test + fun `should not report a missing reference when the form exists globally`() { + val deployDto = FormProcessLinkDeployDto( + processDefinitionId = "process-def:1:123", + activityId = "userTask1", + activityType = USER_TASK_CREATE, + formDefinitionName = "my-form", + subtitles = null + ) + whenever(formDefinitionService.getFormDefinitionByName("my-form")).thenReturn( + Optional.of( + FormIoFormDefinition( + UUID.randomUUID(), + "my-form", + "{}", + FormDefinitionBlueprintId.forCase(caseDefinitionId), + false + ) + ) + ) + + assertThat(formProcessLinkMapper.getMissingReference(deployDto, null)).isNull() + } + + @Test + fun `should look up the form of a case definition when importing for a case`() { + val deployDto = FormProcessLinkDeployDto( + processDefinitionId = "process-def:1:123", + activityId = "userTask1", + activityType = USER_TASK_CREATE, + formDefinitionName = "my-case-form", + subtitles = null + ) + whenever(formDefinitionService.getFormDefinitionByName("my-case-form", caseDefinitionId as BlueprintId)) + .thenReturn(Optional.empty()) + + val missingReference = formProcessLinkMapper.getMissingReference(deployDto, caseDefinitionId) + + assertThat(missingReference!!.reference).isEqualTo("my-case-form") + } + companion object { val SUBTITLES = listOf("test", "test2") } diff --git a/backend/importer/src/main/kotlin/com/ritense/importer/ImportService.kt b/backend/importer/src/main/kotlin/com/ritense/importer/ImportService.kt index 7bf6632a0c..a64623ca59 100644 --- a/backend/importer/src/main/kotlin/com/ritense/importer/ImportService.kt +++ b/backend/importer/src/main/kotlin/com/ritense/importer/ImportService.kt @@ -23,6 +23,10 @@ import java.util.UUID interface ImportService { fun importGlobal(inputStream: InputStream) + fun importGlobal( + inputStream: InputStream, + pluginConfigurationMappings: Map?, + ) = importGlobal(inputStream) fun import(inputStream: InputStream, caseDefinitionIdList: List): CaseDefinitionId? fun import( inputStream: InputStream, @@ -37,6 +41,12 @@ interface ImportService { nameOverride: String?, pluginConfigurationMappings: Map?, ): CaseDefinitionId? = import(inputStream, caseDefinitionIdList, keyOverride, nameOverride) - fun importBuildingBlockDefinitions(inputStream: InputStream, buildingBlockDefinitionIdList: List) - fun importBuildingBlockDefinition(entries: List, buildingBlockDefinitionIdList: List) + fun importBuildingBlockDefinitions( + inputStream: InputStream, + buildingBlockDefinitionIdList: List + ) + fun importBuildingBlockDefinition( + entries: List, + buildingBlockDefinitionIdList: List + ) } diff --git a/backend/importer/src/main/kotlin/com/ritense/importer/ValtimoImportService.kt b/backend/importer/src/main/kotlin/com/ritense/importer/ValtimoImportService.kt index 355661f136..3a7b8eff60 100644 --- a/backend/importer/src/main/kotlin/com/ritense/importer/ValtimoImportService.kt +++ b/backend/importer/src/main/kotlin/com/ritense/importer/ValtimoImportService.kt @@ -203,6 +203,14 @@ class ValtimoImportService( @Transactional override fun importGlobal(inputStream: InputStream) { + importGlobal(inputStream, null) + } + + @Transactional + override fun importGlobal( + inputStream: InputStream, + pluginConfigurationMappings: Map?, + ) { runImporter { val entries = readZipEntries(inputStream) val importerEntriesList = getEntriesByImporter( @@ -213,12 +221,24 @@ class ValtimoImportService( importerEntriesList.filter { !it.key.partOfCaseDefinition() }.forEach { (importer, entries) -> entries.forEach { entry -> logger.debug { "Importing ${entry.fileName} with importer ${importer.type()}" } - importer.import(ImportRequest(entry.fileName, entry.content)) + importer.import( + ImportRequest( + entry.fileName, + entry.content, + pluginConfigurationMappings = pluginConfigurationMappings, + ) + ) } } importerEntriesList.filter { !it.key.partOfCaseDefinition() }.forEach { (importer, entries) -> entries.forEach { entry -> - importer.afterImport(ImportRequest(entry.fileName, entry.content)) + importer.afterImport( + ImportRequest( + entry.fileName, + entry.content, + pluginConfigurationMappings = pluginConfigurationMappings, + ) + ) } } } @@ -455,7 +475,7 @@ class ValtimoImportService( normalizedPath } } else if (normalizedPath.startsWith("config/global")) { - return normalizedPath.substringAfter("config") + normalizedPath.substringAfter("config") } else { normalizedPath } diff --git a/backend/process-link/src/main/kotlin/com/ritense/processlink/configuration/ProcessLinkAutoConfiguration.kt b/backend/process-link/src/main/kotlin/com/ritense/processlink/configuration/ProcessLinkAutoConfiguration.kt index 13419c1369..fab3bc9c73 100644 --- a/backend/process-link/src/main/kotlin/com/ritense/processlink/configuration/ProcessLinkAutoConfiguration.kt +++ b/backend/process-link/src/main/kotlin/com/ritense/processlink/configuration/ProcessLinkAutoConfiguration.kt @@ -19,10 +19,14 @@ package com.ritense.processlink.configuration import com.fasterxml.jackson.databind.ObjectMapper import com.ritense.authorization.AuthorizationService import com.ritense.document.service.DocumentService +import com.ritense.exporter.ExportService +import com.ritense.importer.ImportService import com.ritense.processdocument.service.ProcessDefinitionCaseDefinitionService import com.ritense.processlink.domain.SupportedProcessLinkTypeHandler import com.ritense.processlink.exporter.BuildingBlockProcessLinkToBuildingBlockMapper +import com.ritense.processlink.exporter.GlobalProcessLinkExporter import com.ritense.processlink.exporter.ProcessLinkExporter +import com.ritense.processlink.service.ProcessDefinitionImportPreviewService import com.ritense.processlink.importer.GlobalProcessLinkImporter import com.ritense.processlink.importer.ProcessLinkImporter import com.ritense.processlink.listener.ProcessDefinitionDeletedEventListener @@ -42,6 +46,7 @@ import com.ritense.valtimo.autoconfiguration.ValtimoOperatonAutoConfiguration import com.ritense.valtimo.contract.annotation.ProcessBean import com.ritense.valtimo.contract.buildingblock.BuildingBlockDefinitionChecker import com.ritense.valtimo.contract.case_.CaseDefinitionChecker +import com.ritense.valtimo.contract.importer.ImportPreviewContributor import com.ritense.valtimo.event.ProcessDefinitionDeployedEvent import com.ritense.valtimo.operaton.service.OperatonRepositoryService import com.ritense.valtimo.service.OperatonProcessService @@ -144,7 +149,11 @@ class ProcessLinkAutoConfiguration { repositoryService: RepositoryService, processDeploymentService: ProcessDeploymentService, processDefinitionValidator: ProcessDefinitionValidator, - processPropertyService: ProcessPropertyService + processPropertyService: ProcessPropertyService, + exportService: ExportService, + importService: ImportService, + processDefinitionImportPreviewService: ProcessDefinitionImportPreviewService, + objectMapper: ObjectMapper, ): ProcessLinkResource { return ProcessLinkResource( processLinkService, @@ -154,7 +163,11 @@ class ProcessLinkAutoConfiguration { repositoryService, processDeploymentService, processDefinitionValidator, - processPropertyService + processPropertyService, + exportService, + importService, + processDefinitionImportPreviewService, + objectMapper, ) } @@ -185,6 +198,34 @@ class ProcessLinkAutoConfiguration { buildingBlockMapper, ) + @Bean + @ConditionalOnMissingBean(ProcessDefinitionImportPreviewService::class) + fun processDefinitionImportPreviewService( + objectMapper: ObjectMapper, + importPreviewContributors: List, + processLinkService: ProcessLinkService, + repositoryService: OperatonRepositoryService, + processPropertyService: ProcessPropertyService, + ) = ProcessDefinitionImportPreviewService( + objectMapper, + importPreviewContributors, + processLinkService, + repositoryService, + processPropertyService, + ) + + @Bean + @ConditionalOnMissingBean(GlobalProcessLinkExporter::class) + fun globalProcessLinkExporter( + objectMapper: ObjectMapper, + processLinkService: ProcessLinkService, + repositoryService: OperatonRepositoryService, + ) = GlobalProcessLinkExporter( + objectMapper, + processLinkService, + repositoryService, + ) + @Bean @ConditionalOnMissingBean(ProcessLinkImporter::class) fun processLinkImporter( diff --git a/backend/process-link/src/main/kotlin/com/ritense/processlink/exporter/GlobalProcessLinkExporter.kt b/backend/process-link/src/main/kotlin/com/ritense/processlink/exporter/GlobalProcessLinkExporter.kt new file mode 100644 index 0000000000..f9cf896ab9 --- /dev/null +++ b/backend/process-link/src/main/kotlin/com/ritense/processlink/exporter/GlobalProcessLinkExporter.kt @@ -0,0 +1,76 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.ritense.processlink.exporter + +import com.fasterxml.jackson.databind.ObjectMapper +import com.ritense.exporter.ExportFile +import com.ritense.exporter.ExportPrettyPrinter +import com.ritense.exporter.ExportResult +import com.ritense.exporter.Exporter +import com.ritense.exporter.request.GlobalProcessDefinitionExportRequest +import com.ritense.processlink.service.ProcessLinkService +import com.ritense.valtimo.operaton.repository.OperatonProcessDefinitionSpecificationHelper +import com.ritense.valtimo.operaton.service.OperatonRepositoryService + +/** + * Exports the process links of a process definition that is not part of a case definition. + * + * Unlike [ProcessLinkExporter] this exporter does not create related export requests for the + * definitions a process link points at (forms, form flows). Those can be shared with other + * processes and are exported and imported separately. + */ +class GlobalProcessLinkExporter( + private val objectMapper: ObjectMapper, + private val processLinkService: ProcessLinkService, + private val repositoryService: OperatonRepositoryService, +) : Exporter { + + override fun supports(): Class = + GlobalProcessDefinitionExportRequest::class.java + + override fun export(request: GlobalProcessDefinitionExportRequest): ExportResult { + val processLinks = processLinkService.getProcessLinks(request.processDefinitionId) + + if (processLinks.isEmpty()) { + return ExportResult() + } + + val exportDtos = processLinks.map { processLink -> + processLinkService.getProcessLinkMapper(processLink.processLinkType) + .toProcessLinkExportResponseDto(processLink) + } + + return ExportResult( + ExportFile( + PATH.format(getProcessDefinitionKey(request.processDefinitionId)), + objectMapper.writer(ExportPrettyPrinter()).writeValueAsBytes(exportDtos) + ) + ) + } + + private fun getProcessDefinitionKey(processDefinitionId: String): String { + return requireNotNull( + repositoryService.findProcessDefinition( + OperatonProcessDefinitionSpecificationHelper.byId(processDefinitionId) + ) + ).key + } + + companion object { + private const val PATH = "config/global/process-link/%s.process-link.json" + } +} diff --git a/backend/process-link/src/main/kotlin/com/ritense/processlink/importer/GlobalProcessLinkImporter.kt b/backend/process-link/src/main/kotlin/com/ritense/processlink/importer/GlobalProcessLinkImporter.kt index ed197a0dd1..d6a72a3755 100644 --- a/backend/process-link/src/main/kotlin/com/ritense/processlink/importer/GlobalProcessLinkImporter.kt +++ b/backend/process-link/src/main/kotlin/com/ritense/processlink/importer/GlobalProcessLinkImporter.kt @@ -17,6 +17,9 @@ package com.ritense.processlink.importer import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.databind.node.ArrayNode +import com.ritense.authorization.AuthorizationContext +import com.ritense.importer.ImportRequest import com.ritense.importer.ValtimoImportTypes.Companion.GLOBAL_PROCESS_DEFINITION import com.ritense.importer.ValtimoImportTypes.Companion.GLOBAL_PROCESS_LINK import com.ritense.processdocument.service.ProcessDefinitionCaseDefinitionService @@ -31,10 +34,17 @@ class GlobalProcessLinkImporter( private val processLinkService: ProcessLinkService, repositoryService: OperatonRepositoryService, processDefinitionCaseDefinitionService: ProcessDefinitionCaseDefinitionService, - objectMapper: ObjectMapper, + private val objectMapper: ObjectMapper, processLinkMappers: List, applicationEventPublisher: ApplicationEventPublisher, -) : ProcessLinkImporter(processLinkService, repositoryService, processDefinitionCaseDefinitionService, objectMapper, processLinkMappers, applicationEventPublisher) { +) : ProcessLinkImporter( + processLinkService, + repositoryService, + processDefinitionCaseDefinitionService, + objectMapper, + processLinkMappers, + applicationEventPublisher +) { override fun type() = GLOBAL_PROCESS_LINK override fun dependsOn(): Set { @@ -46,6 +56,39 @@ class GlobalProcessLinkImporter( override fun partOfCaseDefinition() : Boolean = false + /** + * The imported file is the complete set of process links for the process definition, so process + * links that are not in the file are removed. This applies both to importing an exported process + * and to the autodeployment of `config/global/process-link`, which is authoritative. + */ + override fun import(request: ImportRequest) { + deleteProcessLinksNotIn(request) + super.import(request) + } + + private fun deleteProcessLinksNotIn(request: ImportRequest) { + val jsonTree = objectMapper.readTree(request.content.toString(Charsets.UTF_8)) + if (jsonTree !is ArrayNode) { + // Let the import itself report the invalid file + return + } + + val importedActivities = jsonTree.mapNotNull { node -> + val activityId = node.path("activityId").asText(null) ?: return@mapNotNull null + val activityType = node.path("activityType").asText(null) ?: return@mapNotNull null + activityId to activityType + }.toSet() + + val processDefinitionKey = getFilenameRegexToImport().matchEntire(request.fileName)!!.groupValues[1] + val processDefinitionId = AuthorizationContext.runWithoutAuthorization { + resolveProcessDefinitionId(request, processDefinitionKey) + } + + processLinkService.getProcessLinks(processDefinitionId) + .filter { (it.activityId to it.activityType.value) !in importedActivities } + .forEach { processLinkService.deleteProcessLink(it.id) } + } + override fun getFilenameRegexToImport(): Regex { return FILENAME_REGEX } @@ -53,4 +96,4 @@ class GlobalProcessLinkImporter( private companion object { val FILENAME_REGEX = """/global/process-link/(?:.*/)?(.+)\.process-link\.json""".toRegex() } -} \ No newline at end of file +} diff --git a/backend/process-link/src/main/kotlin/com/ritense/processlink/importer/ProcessLinkImporter.kt b/backend/process-link/src/main/kotlin/com/ritense/processlink/importer/ProcessLinkImporter.kt index 5f20a823dc..4db17f89a8 100644 --- a/backend/process-link/src/main/kotlin/com/ritense/processlink/importer/ProcessLinkImporter.kt +++ b/backend/process-link/src/main/kotlin/com/ritense/processlink/importer/ProcessLinkImporter.kt @@ -112,7 +112,7 @@ open class ProcessLinkImporter( } } - private fun resolveProcessDefinitionId(request: ImportRequest, processDefinitionKey: String): String { + protected fun resolveProcessDefinitionId(request: ImportRequest, processDefinitionKey: String): String { val caseDefinitionId = request.caseDefinitionId if (caseDefinitionId == null) { diff --git a/backend/process-link/src/main/kotlin/com/ritense/processlink/mapper/ProcessLinkMapper.kt b/backend/process-link/src/main/kotlin/com/ritense/processlink/mapper/ProcessLinkMapper.kt index fc38f3635a..d9af6fd739 100644 --- a/backend/process-link/src/main/kotlin/com/ritense/processlink/mapper/ProcessLinkMapper.kt +++ b/backend/process-link/src/main/kotlin/com/ritense/processlink/mapper/ProcessLinkMapper.kt @@ -20,6 +20,7 @@ import com.ritense.exporter.manifest.ArtifactDependency import com.ritense.exporter.request.ExportRequest import com.ritense.processlink.autodeployment.ProcessLinkDeployDto import com.ritense.processlink.domain.ProcessLink +import com.ritense.processlink.web.rest.dto.MissingReferenceDto import com.ritense.processlink.web.rest.dto.ProcessLinkCreateRequestDto import com.ritense.processlink.web.rest.dto.ProcessLinkExportResponseDto import com.ritense.processlink.web.rest.dto.ProcessLinkResponseDto @@ -32,7 +33,10 @@ import java.util.UUID interface ProcessLinkMapper { fun supportsProcessLinkType(processLinkType: String): Boolean fun toProcessLinkResponseDto(processLink: ProcessLink): ProcessLinkResponseDto - fun toProcessLinkCreateRequestDto(deployDto: ProcessLinkDeployDto, blueprintId: BlueprintId?): ProcessLinkCreateRequestDto + fun toProcessLinkCreateRequestDto( + deployDto: ProcessLinkDeployDto, + blueprintId: BlueprintId? + ): ProcessLinkCreateRequestDto fun toProcessLinkUpdateRequestDto( deployDto: ProcessLinkDeployDto, existingProcessLinkId: UUID, @@ -53,7 +57,8 @@ interface ProcessLinkMapper { * @param processLink The processLink to create related export requests for * @param caseDefinitionId The caseDefinitionId of the case the processLink is part of */ - fun createRelatedExportRequests(processLink: ProcessLink, caseDefinitionId: CaseDefinitionId): Set = setOf() + fun createRelatedExportRequests(processLink: ProcessLink, caseDefinitionId: CaseDefinitionId): Set = + setOf() /** * Used by the export service to build the export manifest. @@ -64,6 +69,15 @@ interface ProcessLinkMapper { fun getImporterType(): String? = null + /** + * Used when previewing an import. + * Should return the definition this process link points at when it is not available, so the user + * can be told what is missing before the import is attempted. + * @param deployDto The process link as present in the import + * @param blueprintId The case or building block the process link will be imported for, if any + */ + fun getMissingReference(deployDto: ProcessLinkDeployDto, blueprintId: BlueprintId?): MissingReferenceDto? = null + /** * Called after all imports for a case definition are complete. * Used to check for configuration issues (e.g. missing plugin configurations). diff --git a/backend/process-link/src/main/kotlin/com/ritense/processlink/security/config/ProcessLinkHttpSecurityConfigurer.kt b/backend/process-link/src/main/kotlin/com/ritense/processlink/security/config/ProcessLinkHttpSecurityConfigurer.kt index ee7b427414..5a0d7e93af 100644 --- a/backend/process-link/src/main/kotlin/com/ritense/processlink/security/config/ProcessLinkHttpSecurityConfigurer.kt +++ b/backend/process-link/src/main/kotlin/com/ritense/processlink/security/config/ProcessLinkHttpSecurityConfigurer.kt @@ -126,6 +126,24 @@ class ProcessLinkHttpSecurityConfigurer : HttpSecurityConfigurer { "/api/management/v1/process-definition/validate" ) ).hasAuthority(ADMIN) + .requestMatchers( + antMatcher( + GET, + "/api/management/v1/process-definition/{processDefinitionId}/export" + ) + ).hasAuthority(ADMIN) + .requestMatchers( + antMatcher( + POST, + "/api/management/v1/process-definition/import/preview" + ) + ).hasAuthority(ADMIN) + .requestMatchers( + antMatcher( + POST, + "/api/management/v1/process-definition/import" + ) + ).hasAuthority(ADMIN) } } catch (e: Exception) { throw HttpConfigurerConfigurationException(e) diff --git a/backend/process-link/src/main/kotlin/com/ritense/processlink/service/ProcessDefinitionImportPreviewService.kt b/backend/process-link/src/main/kotlin/com/ritense/processlink/service/ProcessDefinitionImportPreviewService.kt new file mode 100644 index 0000000000..cc4d56ac9e --- /dev/null +++ b/backend/process-link/src/main/kotlin/com/ritense/processlink/service/ProcessDefinitionImportPreviewService.kt @@ -0,0 +1,245 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.ritense.processlink.service + +import com.fasterxml.jackson.databind.JsonNode +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.databind.node.ArrayNode +import com.fasterxml.jackson.databind.node.ObjectNode +import com.fasterxml.jackson.databind.node.TextNode +import com.fasterxml.jackson.module.kotlin.treeToValue +import com.ritense.authorization.AuthorizationContext.Companion.runWithoutAuthorization +import com.ritense.importer.exception.ImportServiceException +import com.ritense.processlink.autodeployment.ProcessLinkDeployDto +import com.ritense.processlink.web.rest.dto.MissingReferenceDto +import com.ritense.processlink.web.rest.dto.MissingReferenceType +import com.ritense.processlink.web.rest.dto.ProcessLinkPluginConfigurationPreviewDto +import com.ritense.processlink.web.rest.dto.ProcessDefinitionImportPreviewResponseDto +import com.ritense.valtimo.contract.importer.ImportPreviewContributor +import com.ritense.valtimo.operaton.repository.OperatonDecisionDefinitionSpecificationHelper +import com.ritense.valtimo.operaton.service.OperatonRepositoryService +import com.ritense.valtimo.service.ProcessPropertyService +import io.github.oshai.kotlinlogging.KotlinLogging +import org.operaton.bpm.model.bpmn.Bpmn +import org.operaton.bpm.model.bpmn.instance.BusinessRuleTask +import org.operaton.bpm.model.bpmn.instance.CallActivity +import java.io.InputStream +import java.util.zip.ZipInputStream + +/** + * Inspects a process definition import before it is applied, so the user can be told what the + * package contains, which plugin configurations have to be mapped, and what the package refers to + * that is not available here. + */ +class ProcessDefinitionImportPreviewService( + private val objectMapper: ObjectMapper, + private val importPreviewContributors: List, + private val processLinkService: ProcessLinkService, + private val repositoryService: OperatonRepositoryService, + private val processPropertyService: ProcessPropertyService, +) { + + fun preview(inputStream: InputStream): ProcessDefinitionImportPreviewResponseDto { + val zipEntries = readZipEntries(inputStream) + + val bpmnEntries = zipEntries.filterKeys { it.matches(BPMN_REGEX) } + if (bpmnEntries.isEmpty()) { + throw ImportServiceException("No process definition found in the provided archive") + } + + val processDefinitionKeys = bpmnEntries.keys.map { fileName -> + BPMN_REGEX.matchEntire(fileName)!!.groupValues[1] + } + + val pluginConfigurations = importPreviewContributors.flatMap { it.contributePreview(zipEntries) } + .map { + ProcessLinkPluginConfigurationPreviewDto( + pluginConfigurationId = it.pluginConfigurationId, + pluginDefinitionKey = it.pluginDefinitionKey, + pluginActionDefinitionKey = it.pluginActionDefinitionKey, + processDefinitionKey = it.processDefinitionKey, + activityId = it.activityId, + existsInTargetEnvironment = it.existsInTargetEnvironment, + ) + } + + return runWithoutAuthorization { + val existingProcessDefinitionKeys = processDefinitionKeys.filter { + repositoryService.findLatestProcessDefinition(it) != null + } + + ProcessDefinitionImportPreviewResponseDto( + processDefinitionKeys = processDefinitionKeys, + existingProcessDefinitionKeys = existingProcessDefinitionKeys, + pluginConfigurations = pluginConfigurations, + missingReferences = findReadOnlySystemProcesses(existingProcessDefinitionKeys) + + findMissingBpmnReferences(bpmnEntries, processDefinitionKeys, zipEntries) + + findMissingProcessLinkReferences(zipEntries), + ) + } + } + + /** + * A process that is present here as a system process that may not be updated is managed by the + * application configuration, which stays authoritative. Note that autodeployment of + * `config/global` deliberately does overwrite it. + */ + private fun findReadOnlySystemProcesses(existingProcessDefinitionKeys: List): List { + return existingProcessDefinitionKeys.filter { isReadOnly(it) } + .map { + MissingReferenceDto( + type = MissingReferenceType.READ_ONLY_SYSTEM_PROCESS, + reference = it, + processDefinitionKey = it, + ) + } + } + + private fun isReadOnly(processDefinitionKey: String): Boolean { + // isReadOnly throws when there are no properties for the key, which is the case for a new process + processPropertyService.findByProcessDefinitionKey(processDefinitionKey) ?: return false + return processPropertyService.isReadOnly(processDefinitionKey) + } + + private fun findMissingBpmnReferences( + bpmnEntries: Map, + processDefinitionKeys: List, + zipEntries: Map, + ): List { + val decisionKeysInPackage = zipEntries.keys.mapNotNull { DMN_REGEX.matchEntire(it)?.groupValues?.get(1) } + + return bpmnEntries.flatMap { (fileName, content) -> + val processDefinitionKey = BPMN_REGEX.matchEntire(fileName)!!.groupValues[1] + val bpmnModel = try { + content.inputStream().use { Bpmn.readModelFromStream(it) } + } catch (e: Exception) { + logger.info(e) { "Could not read '$fileName' while previewing the import" } + return@flatMap emptyList() + } + + val missingSubProcesses = bpmnModel.getModelElementsByType(CallActivity::class.java) + .mapNotNull { callActivity -> + val calledElement = callActivity.calledElement ?: return@mapNotNull null + if (calledElement in processDefinitionKeys) return@mapNotNull null + if (repositoryService.findLatestProcessDefinition(calledElement) != null) return@mapNotNull null + MissingReferenceDto( + type = MissingReferenceType.SUB_PROCESS, + reference = calledElement, + activityId = callActivity.id, + processDefinitionKey = processDefinitionKey, + ) + } + + val missingDecisions = bpmnModel.getModelElementsByType(BusinessRuleTask::class.java) + .mapNotNull { businessRuleTask -> + val decisionRef = businessRuleTask.operatonDecisionRef ?: return@mapNotNull null + if (decisionRef in decisionKeysInPackage) return@mapNotNull null + if (decisionDefinitionExists(decisionRef)) return@mapNotNull null + MissingReferenceDto( + type = MissingReferenceType.DECISION_DEFINITION, + reference = decisionRef, + activityId = businessRuleTask.id, + processDefinitionKey = processDefinitionKey, + ) + } + + missingSubProcesses + missingDecisions + } + } + + private fun decisionDefinitionExists(decisionDefinitionKey: String): Boolean { + return repositoryService.findDecisionDefinition( + OperatonDecisionDefinitionSpecificationHelper.byKey(decisionDefinitionKey) + .and(OperatonDecisionDefinitionSpecificationHelper.byLatestVersion()) + ) != null + } + + private fun findMissingProcessLinkReferences(zipEntries: Map): List { + return zipEntries.filterKeys { it.matches(PROCESS_LINK_REGEX) } + .flatMap { (fileName, content) -> + val processDefinitionKey = PROCESS_LINK_REGEX.matchEntire(fileName)!!.groupValues[1] + val jsonTree = try { + objectMapper.readTree(content.toString(Charsets.UTF_8)) + } catch (e: Exception) { + logger.info(e) { "Could not read '$fileName' while previewing the import" } + return@flatMap emptyList() + } + if (jsonTree !is ArrayNode) { + return@flatMap emptyList() + } + + jsonTree.mapNotNull { node -> + getMissingReference(node, processDefinitionKey) + } + } + } + + private fun getMissingReference(node: JsonNode, processDefinitionKey: String): MissingReferenceDto? { + if (node !is ObjectNode) { + return null + } + // The process definition is only known once the process is deployed, so a placeholder is used + if (!node.has(PROCESS_DEFINITION_ID)) { + node.set(PROCESS_DEFINITION_ID, TextNode.valueOf(PROCESS_DEFINITION_ID_PLACEHOLDER)) + } + + val deployDto = try { + objectMapper.treeToValue(node) + } catch (e: Exception) { + logger.info(e) { "Could not read process link of process '$processDefinitionKey' while previewing the import" } + return null + } + + return processLinkService.getProcessLinkMapper(deployDto.processLinkType) + // A process definition outside a case definition has no blueprint + .getMissingReference(deployDto, null) + ?.copy(processDefinitionKey = processDefinitionKey) + } + + private fun readZipEntries(inputStream: InputStream): Map { + val entries = mutableMapOf() + + try { + ZipInputStream(inputStream).use { zis -> + var entry = zis.nextEntry + while (entry != null) { + if (!entry.isDirectory) { + entries[entry.name] = zis.readBytes() + } + entry = zis.nextEntry + } + } + } catch (e: Exception) { + throw ImportServiceException("Archive could not be read: ${e.message}") + } + + if (entries.isEmpty()) { + throw ImportServiceException("Archive was empty or not a zip") + } + + return entries + } + + companion object { + private val logger = KotlinLogging.logger {} + private const val PROCESS_DEFINITION_ID = "processDefinitionId" + private const val PROCESS_DEFINITION_ID_PLACEHOLDER = "-" + private val BPMN_REGEX = """.*/?global/bpmn/(?:.*/)?(.+)\.bpmn""".toRegex() + private val DMN_REGEX = """.*/?global/dmn/(?:.*/)?(.+)\.dmn""".toRegex() + private val PROCESS_LINK_REGEX = """.*/?process-link/(?:.*/)?(.+)\.process-link\.json""".toRegex() + } +} diff --git a/backend/process-link/src/main/kotlin/com/ritense/processlink/web/rest/ProcessLinkResource.kt b/backend/process-link/src/main/kotlin/com/ritense/processlink/web/rest/ProcessLinkResource.kt index 08b2413bd6..75e931c929 100644 --- a/backend/process-link/src/main/kotlin/com/ritense/processlink/web/rest/ProcessLinkResource.kt +++ b/backend/process-link/src/main/kotlin/com/ritense/processlink/web/rest/ProcessLinkResource.kt @@ -16,7 +16,13 @@ package com.ritense.processlink.web.rest +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.module.kotlin.readValue import com.ritense.authorization.AuthorizationContext.Companion.runWithoutAuthorization +import com.ritense.exporter.ExportService +import com.ritense.exporter.request.GlobalProcessDefinitionExportRequest +import com.ritense.importer.ImportService +import com.ritense.importer.exception.ImportServiceException import com.ritense.logging.LoggableResource import com.ritense.logging.withLoggingContext import com.ritense.processdocument.domain.ProcessDefinitionId @@ -24,9 +30,12 @@ import com.ritense.processdocument.service.ProcessDefinitionCaseDefinitionServic import com.ritense.processlink.domain.ProcessLink import com.ritense.processlink.domain.ProcessLinkType import com.ritense.processlink.mapper.ProcessLinkMapper +import com.ritense.processlink.service.ProcessDefinitionImportPreviewService import com.ritense.processlink.service.ProcessDeploymentService import com.ritense.processlink.service.ProcessLinkService import com.ritense.processlink.web.rest.dto.CaseProcessDefinitionResponseDto +import com.ritense.processlink.web.rest.dto.ProcessDefinitionImportPreviewResponseDto +import com.ritense.processlink.web.rest.dto.ProcessDefinitionImportResponseDto import com.ritense.processlink.web.rest.dto.ProcessDefinitionResponseDto import com.ritense.processlink.web.rest.dto.ProcessLinkCreateRequestDto import com.ritense.processlink.web.rest.dto.ProcessDefinitionValidateRequestDto @@ -43,6 +52,7 @@ import com.ritense.valtimo.contract.domain.ValtimoMediaType.APPLICATION_JSON_UTF import com.ritense.valtimo.service.OperatonProcessService import com.ritense.valtimo.service.ProcessPropertyService import com.ritense.valtimo.web.rest.dto.ProcessDefinitionWithPropertiesDto +import io.github.oshai.kotlinlogging.KotlinLogging import jakarta.validation.Valid import org.operaton.bpm.engine.RepositoryService import org.operaton.bpm.model.bpmn.Bpmn @@ -63,6 +73,8 @@ import org.springframework.web.bind.annotation.RequestPart import org.springframework.web.bind.annotation.RestController import org.springframework.web.multipart.MultipartFile import java.nio.charset.StandardCharsets +import java.time.LocalDateTime +import java.time.format.DateTimeFormatter import java.util.UUID import java.util.stream.Collectors @@ -77,7 +89,11 @@ class ProcessLinkResource( private val repositoryService: RepositoryService, private val processDeploymentService: ProcessDeploymentService, private val processDefinitionValidator: ProcessDefinitionValidator, - private val processPropertyService: ProcessPropertyService + private val processPropertyService: ProcessPropertyService, + private val exportService: ExportService, + private val importService: ImportService, + private val processDefinitionImportPreviewService: ProcessDefinitionImportPreviewService, + private val objectMapper: ObjectMapper, ) { @GetMapping("/v1/process-link") @@ -484,6 +500,69 @@ class ProcessLinkResource( ?: throw IllegalStateException("No ProcessLinkMapper found for processLinkType $processLinkType") } + @GetMapping( + value = ["/management/v1/process-definition/{processDefinitionId}/export"], + produces = [MediaType.APPLICATION_OCTET_STREAM_VALUE] + ) + fun exportProcessDefinition( + @LoggableResource(resourceType = OperatonProcessDefinition::class) @PathVariable processDefinitionId: String + ): ResponseEntity { + return runWithoutAuthorization { + val processDefinition = operatonProcessService.getProcessDefinitionById(processDefinitionId) + val outputStream = exportService.export(GlobalProcessDefinitionExportRequest(processDefinitionId)) + val timestamp = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd_HH-mm")) + val fileName = "${processDefinition.key}_v${processDefinition.version}_$timestamp.process.zip" + + ResponseEntity + .ok() + .header("Content-Disposition", "attachment;filename=$fileName") + .body(outputStream.toByteArray()) + } + } + + @PostMapping("/management/v1/process-definition/import/preview") + fun previewProcessDefinitionImport( + @RequestParam("file") file: MultipartFile + ): ResponseEntity { + return try { + ResponseEntity.ok(processDefinitionImportPreviewService.preview(file.inputStream)) + } catch (exception: ImportServiceException) { + logger.info(exception) { "Process definition import preview failed" } + ResponseEntity.badRequest().build() + } + } + + @PostMapping("/management/v1/process-definition/import") + fun importProcessDefinition( + @RequestParam("file") file: MultipartFile, + @RequestPart("pluginConfigurationMappings", required = false) pluginConfigurationMappingsJson: String?, + ): ResponseEntity { + return try { + val preview = processDefinitionImportPreviewService.preview(file.inputStream) + val response = ProcessDefinitionImportResponseDto( + processDefinitionKeys = preview.processDefinitionKeys, + missingReferences = preview.missingReferences, + ) + + // Importing would either fail or overwrite a process that is managed by configuration + if (!preview.canImport) { + return ResponseEntity.badRequest().body(response) + } + + val pluginConfigurationMappings: Map? = pluginConfigurationMappingsJson?.let { + objectMapper.readValue>(it) + } + runWithoutAuthorization { + importService.importGlobal(file.inputStream, pluginConfigurationMappings) + } + + ResponseEntity.ok(response) + } catch (exception: ImportServiceException) { + logger.info(exception) { "Process definition import failed" } + ResponseEntity.badRequest().build() + } + } + private fun getBpmnXml(definition: OperatonProcessDefinition): String { val xml = String( IoUtil.readInputStream( @@ -496,4 +575,8 @@ class ProcessLinkResource( } return xml } + + companion object { + private val logger = KotlinLogging.logger {} + } } diff --git a/backend/process-link/src/main/kotlin/com/ritense/processlink/web/rest/dto/MissingReferenceDto.kt b/backend/process-link/src/main/kotlin/com/ritense/processlink/web/rest/dto/MissingReferenceDto.kt new file mode 100644 index 0000000000..7eb400ffb6 --- /dev/null +++ b/backend/process-link/src/main/kotlin/com/ritense/processlink/web/rest/dto/MissingReferenceDto.kt @@ -0,0 +1,49 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.ritense.processlink.web.rest.dto + +/** + * Something an imported process refers to that is not present on this environment. + * + * Only statically determinable references are reported: call activities with a literal called + * element, business rule tasks with a literal decision reference, and form / form flow process + * links. Expression based references cannot be resolved up front. + */ +data class MissingReferenceDto( + val type: MissingReferenceType, + val reference: String, + val activityId: String? = null, + val processDefinitionKey: String? = null, +) { + /** + * Whether importing would fail on this missing reference. Form and form flow process links + * cannot be created without their definition, which fails the entire import. + */ + val blocksImport: Boolean get() = type.blocksImport +} + +enum class MissingReferenceType(val blocksImport: Boolean) { + SUB_PROCESS(false), + DECISION_DEFINITION(false), + FORM(true), + FORM_FLOW(true), + + /** + * The process already exists on this environment as a system process that may not be updated. + */ + READ_ONLY_SYSTEM_PROCESS(true), +} diff --git a/backend/process-link/src/main/kotlin/com/ritense/processlink/web/rest/dto/ProcessDefinitionImportPreviewResponseDto.kt b/backend/process-link/src/main/kotlin/com/ritense/processlink/web/rest/dto/ProcessDefinitionImportPreviewResponseDto.kt new file mode 100644 index 0000000000..f5ed3a89d2 --- /dev/null +++ b/backend/process-link/src/main/kotlin/com/ritense/processlink/web/rest/dto/ProcessDefinitionImportPreviewResponseDto.kt @@ -0,0 +1,44 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.ritense.processlink.web.rest.dto + +import java.util.UUID + +data class ProcessDefinitionImportPreviewResponseDto( + val processDefinitionKeys: List, + /** + * The processes of the package that already exist here and will be replaced by the import. + */ + val existingProcessDefinitionKeys: List = emptyList(), + val pluginConfigurations: List = emptyList(), + val missingReferences: List = emptyList(), +) { + val canImport: Boolean get() = missingReferences.none { it.blocksImport } +} + +/** + * Mirrors the case import equivalent. Kept separate so the process-link module does not depend on + * the case module's REST contract. + */ +data class ProcessLinkPluginConfigurationPreviewDto( + val pluginConfigurationId: UUID, + val pluginDefinitionKey: String?, + val pluginActionDefinitionKey: String, + val processDefinitionKey: String, + val activityId: String, + val existsInTargetEnvironment: Boolean, +) diff --git a/backend/process-link/src/main/kotlin/com/ritense/processlink/web/rest/dto/ProcessDefinitionImportResponseDto.kt b/backend/process-link/src/main/kotlin/com/ritense/processlink/web/rest/dto/ProcessDefinitionImportResponseDto.kt new file mode 100644 index 0000000000..5fb089d90c --- /dev/null +++ b/backend/process-link/src/main/kotlin/com/ritense/processlink/web/rest/dto/ProcessDefinitionImportResponseDto.kt @@ -0,0 +1,22 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.ritense.processlink.web.rest.dto + +data class ProcessDefinitionImportResponseDto( + val processDefinitionKeys: List, + val missingReferences: List = emptyList(), +) diff --git a/backend/process-link/src/test/kotlin/com/ritense/processlink/exporter/GlobalProcessLinkExporterIntTest.kt b/backend/process-link/src/test/kotlin/com/ritense/processlink/exporter/GlobalProcessLinkExporterIntTest.kt new file mode 100644 index 0000000000..ecc5fad16b --- /dev/null +++ b/backend/process-link/src/test/kotlin/com/ritense/processlink/exporter/GlobalProcessLinkExporterIntTest.kt @@ -0,0 +1,106 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.ritense.processlink.exporter + +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.module.kotlin.readValue +import com.ritense.authorization.AuthorizationContext.Companion.runWithoutAuthorization +import com.ritense.exporter.ExportService +import com.ritense.exporter.request.GlobalProcessDefinitionExportRequest +import com.ritense.processlink.BaseIntegrationTest +import com.ritense.processlink.web.rest.dto.ProcessLinkExportResponseDto +import com.ritense.valtimo.operaton.service.OperatonRepositoryService +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.transaction.annotation.Transactional +import java.io.ByteArrayInputStream +import java.util.zip.ZipInputStream + +/** + * Exports the autodeployed `test-system-process`, which is not part of a case definition. + */ +@Transactional +class GlobalProcessLinkExporterIntTest @Autowired constructor( + private val objectMapper: ObjectMapper, + private val operatonRepositoryService: OperatonRepositoryService, + private val exportService: ExportService, +) : BaseIntegrationTest() { + + @Test + fun `should export the process and its process links into the global folder structure`(): Unit = + runWithoutAuthorization { + val processDefinitionId = getProcessDefinitionId(PROCESS_DEFINITION_KEY) + + val entries = export(processDefinitionId) + + assertThat(entries.keys).containsExactlyInAnyOrder( + "config/global/bpmn/$PROCESS_DEFINITION_KEY.bpmn", + "config/global/process-link/$PROCESS_DEFINITION_KEY.process-link.json", + ) + } + + @Test + fun `should export the process links of the process`(): Unit = runWithoutAuthorization { + val processDefinitionId = getProcessDefinitionId(PROCESS_DEFINITION_KEY) + + val entries = export(processDefinitionId) + + val processLinks: List = objectMapper.readValue( + entries.getValue("config/global/process-link/$PROCESS_DEFINITION_KEY.process-link.json") + ) + assertThat(processLinks).hasSize(1) + assertThat(processLinks.single().activityId).isEqualTo("test-user-task") + assertThat(processLinks.single().processLinkType).isEqualTo("test") + } + + @Test + fun `should export a bpmn that contains the process definition key`(): Unit = runWithoutAuthorization { + val processDefinitionId = getProcessDefinitionId(PROCESS_DEFINITION_KEY) + + val entries = export(processDefinitionId) + + val bpmn = entries.getValue("config/global/bpmn/$PROCESS_DEFINITION_KEY.bpmn").toString(Charsets.UTF_8) + assertThat(bpmn).contains("""id="$PROCESS_DEFINITION_KEY"""") + assertThat(bpmn).contains("test-user-task") + } + + private fun export(processDefinitionId: String): Map { + val outputStream = exportService.export(GlobalProcessDefinitionExportRequest(processDefinitionId)) + return readZipEntries(outputStream.toByteArray()) + } + + private fun readZipEntries(zip: ByteArray): Map { + val entries = mutableMapOf() + ZipInputStream(ByteArrayInputStream(zip)).use { zipInputStream -> + var entry = zipInputStream.nextEntry + while (entry != null) { + entries[entry.name] = zipInputStream.readBytes() + entry = zipInputStream.nextEntry + } + } + return entries + } + + private fun getProcessDefinitionId(processDefinitionKey: String): String { + return requireNotNull(operatonRepositoryService.findLatestProcessDefinition(processDefinitionKey)).id + } + + private companion object { + const val PROCESS_DEFINITION_KEY = "test-system-process" + } +} diff --git a/backend/process-link/src/test/kotlin/com/ritense/processlink/exporter/GlobalProcessLinkExporterTest.kt b/backend/process-link/src/test/kotlin/com/ritense/processlink/exporter/GlobalProcessLinkExporterTest.kt new file mode 100644 index 0000000000..fb1e3450bb --- /dev/null +++ b/backend/process-link/src/test/kotlin/com/ritense/processlink/exporter/GlobalProcessLinkExporterTest.kt @@ -0,0 +1,131 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.ritense.processlink.exporter + +import com.ritense.exporter.request.GlobalProcessDefinitionExportRequest +import com.ritense.processlink.domain.ActivityTypeWithEventName +import com.ritense.processlink.domain.ProcessLink +import com.ritense.processlink.mapper.ProcessLinkMapper +import com.ritense.processlink.service.ProcessLinkService +import com.ritense.processlink.web.rest.dto.ProcessLinkExportResponseDto +import com.ritense.valtimo.contract.json.MapperSingleton +import com.ritense.valtimo.operaton.domain.OperatonProcessDefinition +import com.ritense.valtimo.operaton.service.OperatonRepositoryService +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.mockito.Mock +import org.mockito.junit.jupiter.MockitoExtension +import org.mockito.kotlin.any +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import org.springframework.data.jpa.domain.Specification + +@ExtendWith(MockitoExtension::class) +class GlobalProcessLinkExporterTest { + + @Mock + lateinit var processLinkService: ProcessLinkService + + @Mock + lateinit var repositoryService: OperatonRepositoryService + + private lateinit var exporter: GlobalProcessLinkExporter + + @BeforeEach + fun before() { + exporter = GlobalProcessLinkExporter( + MapperSingleton.get(), + processLinkService, + repositoryService, + ) + } + + @Test + fun `should export process links into the global folder structure`() { + val processLink = mock() + val mapper = mapperReturning() + whenever(processLink.processLinkType).thenReturn("test-type") + whenever(processLinkService.getProcessLinks(PROCESS_DEFINITION_ID)).thenReturn(listOf(processLink)) + whenever(processLinkService.getProcessLinkMapper("test-type")).thenReturn(mapper) + mockProcessDefinition() + + val result = exporter.export(GlobalProcessDefinitionExportRequest(PROCESS_DEFINITION_ID)) + + val exportFile = result.exportFiles.single() + assertThat(exportFile.path).isEqualTo("config/global/process-link/my-process.process-link.json") + assertThat(exportFile.content.toString(Charsets.UTF_8)) + .contains("\"activityId\" : \"Task_1\"") + .contains("\"processLinkType\" : \"test-type\"") + } + + @Test + fun `should not create related export requests for referenced definitions`() { + val processLink = mock() + val mapper = mapperReturning() + whenever(processLink.processLinkType).thenReturn("test-type") + whenever(processLinkService.getProcessLinks(PROCESS_DEFINITION_ID)).thenReturn(listOf(processLink)) + whenever(processLinkService.getProcessLinkMapper("test-type")).thenReturn(mapper) + mockProcessDefinition() + + val result = exporter.export(GlobalProcessDefinitionExportRequest(PROCESS_DEFINITION_ID)) + + assertThat(result.relatedRequests).isEmpty() + } + + @Test + fun `should export nothing when the process has no process links`() { + whenever(processLinkService.getProcessLinks(PROCESS_DEFINITION_ID)).thenReturn(emptyList()) + + val result = exporter.export(GlobalProcessDefinitionExportRequest(PROCESS_DEFINITION_ID)) + + assertThat(result.exportFiles).isEmpty() + assertThat(result.relatedRequests).isEmpty() + } + + @Test + fun `should support the global process definition export request`() { + assertThat(exporter.supports()).isEqualTo(GlobalProcessDefinitionExportRequest::class.java) + } + + private fun mockProcessDefinition() { + val processDefinition = mock() + whenever(processDefinition.key).thenReturn("my-process") + whenever(repositoryService.findProcessDefinition(any>())) + .thenReturn(processDefinition) + } + + private fun mapperReturning(): ProcessLinkMapper { + val mapper = mock() + whenever(mapper.toProcessLinkExportResponseDto(any())).thenReturn( + TestProcessLinkExportResponseDto("Task_1", ActivityTypeWithEventName.SERVICE_TASK_START) + ) + return mapper + } + + private class TestProcessLinkExportResponseDto( + override val activityId: String, + override val activityType: ActivityTypeWithEventName, + ) : ProcessLinkExportResponseDto { + override val processLinkType: String = "test-type" + } + + private companion object { + const val PROCESS_DEFINITION_ID = "pd-1" + } +} diff --git a/backend/process-link/src/test/kotlin/com/ritense/processlink/importer/GlobalProcessDefinitionRoundTripIntTest.kt b/backend/process-link/src/test/kotlin/com/ritense/processlink/importer/GlobalProcessDefinitionRoundTripIntTest.kt new file mode 100644 index 0000000000..8ce4468786 --- /dev/null +++ b/backend/process-link/src/test/kotlin/com/ritense/processlink/importer/GlobalProcessDefinitionRoundTripIntTest.kt @@ -0,0 +1,96 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.ritense.processlink.importer + +import com.ritense.authorization.AuthorizationContext.Companion.runWithoutAuthorization +import com.ritense.exporter.ExportService +import com.ritense.exporter.request.GlobalProcessDefinitionExportRequest +import com.ritense.importer.ImportService +import com.ritense.processlink.BaseIntegrationTest +import com.ritense.processlink.domain.ActivityTypeWithEventName +import com.ritense.processlink.domain.TestProcessLink +import com.ritense.processlink.domain.TestProcessLinkCreateRequestDto +import com.ritense.processlink.service.ProcessLinkService +import com.ritense.valtimo.operaton.service.OperatonRepositoryService +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.transaction.annotation.Transactional +import java.io.ByteArrayInputStream + +/** + * Exports the autodeployed `test-system-process` and imports the resulting package again, which is + * how a process is moved to another environment. + */ +@Transactional +class GlobalProcessDefinitionRoundTripIntTest @Autowired constructor( + private val exportService: ExportService, + private val importService: ImportService, + private val processLinkService: ProcessLinkService, + private val repositoryService: OperatonRepositoryService, +) : BaseIntegrationTest() { + + @Test + fun `should restore the process links of the package`(): Unit = runWithoutAuthorization { + val exported = export() + val originalLinks = processLinkService.getProcessLinks(processDefinitionId()) + assertThat(originalLinks).hasSize(1) + + processLinkService.deleteProcessLink(originalLinks.single().id) + assertThat(processLinkService.getProcessLinks(processDefinitionId())).isEmpty() + + importService.importGlobal(ByteArrayInputStream(exported)) + + val importedLinks = processLinkService.getProcessLinks(processDefinitionId()) + assertThat(importedLinks).hasSize(1) + assertThat(importedLinks.single().activityId).isEqualTo(originalLinks.single().activityId) + assertThat(importedLinks.single().activityType).isEqualTo(originalLinks.single().activityType) + assertThat((importedLinks.single() as TestProcessLink).someValue) + .isEqualTo((originalLinks.single() as TestProcessLink).someValue) + } + + @Test + fun `should remove a process link that was added after the export`(): Unit = runWithoutAuthorization { + val exported = export() + processLinkService.createProcessLink( + TestProcessLinkCreateRequestDto( + processDefinitionId = processDefinitionId(), + activityId = "another-user-task", + activityType = ActivityTypeWithEventName.USER_TASK_CREATE, + someValue = "added after the export", + ), + null, + ) + assertThat(processLinkService.getProcessLinks(processDefinitionId())).hasSize(2) + + importService.importGlobal(ByteArrayInputStream(exported)) + + val processLinks = processLinkService.getProcessLinks(processDefinitionId()) + assertThat(processLinks).hasSize(1) + assertThat(processLinks.single().activityId).isEqualTo("test-user-task") + } + + private fun export(): ByteArray = + exportService.export(GlobalProcessDefinitionExportRequest(processDefinitionId())).toByteArray() + + private fun processDefinitionId(): String = + requireNotNull(repositoryService.findLatestProcessDefinition(PROCESS_DEFINITION_KEY)).id + + private companion object { + const val PROCESS_DEFINITION_KEY = "test-system-process" + } +} diff --git a/backend/process-link/src/test/kotlin/com/ritense/processlink/importer/GlobalProcessLinkImporterIntTest.kt b/backend/process-link/src/test/kotlin/com/ritense/processlink/importer/GlobalProcessLinkImporterIntTest.kt new file mode 100644 index 0000000000..2bef57066d --- /dev/null +++ b/backend/process-link/src/test/kotlin/com/ritense/processlink/importer/GlobalProcessLinkImporterIntTest.kt @@ -0,0 +1,126 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.ritense.processlink.importer + +import com.ritense.authorization.AuthorizationContext.Companion.runWithoutAuthorization +import com.ritense.importer.ImportRequest +import com.ritense.processlink.BaseIntegrationTest +import com.ritense.processlink.domain.ActivityTypeWithEventName +import com.ritense.processlink.domain.TestProcessLink +import com.ritense.processlink.domain.TestProcessLinkCreateRequestDto +import com.ritense.processlink.service.ProcessLinkService +import com.ritense.valtimo.operaton.service.OperatonRepositoryService +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.transaction.annotation.Transactional + +/** + * Imports process links for the autodeployed `test-system-process`, which is not part of a case + * definition. The imported file is the complete set of process links for the process. + */ +@Transactional +class GlobalProcessLinkImporterIntTest @Autowired constructor( + private val globalProcessLinkImporter: GlobalProcessLinkImporter, + private val processLinkService: ProcessLinkService, + private val repositoryService: OperatonRepositoryService, +) : BaseIntegrationTest() { + + @Test + fun `should update the process link that is in the imported file`(): Unit = runWithoutAuthorization { + globalProcessLinkImporter.import(importRequest(json("test-user-task", "imported value"))) + + val processLinks = processLinkService.getProcessLinks(processDefinitionId()) + assertThat(processLinks).hasSize(1) + assertThat((processLinks.single() as TestProcessLink).someValue).isEqualTo("imported value") + } + + @Test + fun `should delete a process link that is not in the imported file`(): Unit = runWithoutAuthorization { + val addedLink = processLinkService.createProcessLink( + TestProcessLinkCreateRequestDto( + processDefinitionId = processDefinitionId(), + activityId = "another-user-task", + activityType = ActivityTypeWithEventName.USER_TASK_CREATE, + someValue = "added through the interface", + ), + null, + ) + assertThat(processLinkService.getProcessLinks(processDefinitionId())).hasSize(2) + + globalProcessLinkImporter.import(importRequest(json("test-user-task", "imported value"))) + + val processLinks = processLinkService.getProcessLinks(processDefinitionId()) + assertThat(processLinks).hasSize(1) + assertThat(processLinks.single().activityId).isEqualTo("test-user-task") + assertThat(processLinks.map { it.id }).doesNotContain(addedLink.id) + } + + @Test + fun `should delete every process link when the imported file is empty`(): Unit = runWithoutAuthorization { + globalProcessLinkImporter.import(importRequest("[]")) + + assertThat(processLinkService.getProcessLinks(processDefinitionId())).isEmpty() + } + + /** + * A process definition can hold only one process link per activity, so an activity type that + * changed in the source has to replace the process link of the target instead of updating it. + */ + @Test + fun `should replace a process link of which the activity type changed`(): Unit = runWithoutAuthorization { + val existingLink = processLinkService.getProcessLinks(processDefinitionId()).single() + processLinkService.deleteProcessLink(existingLink.id) + processLinkService.createProcessLink( + TestProcessLinkCreateRequestDto( + processDefinitionId = processDefinitionId(), + activityId = "test-user-task", + activityType = ActivityTypeWithEventName.USER_TASK_COMPLETE, + someValue = "before the import", + ), + null, + ) + + globalProcessLinkImporter.import(importRequest(json("test-user-task", "imported value"))) + + val processLinks = processLinkService.getProcessLinks(processDefinitionId()) + assertThat(processLinks).hasSize(1) + assertThat(processLinks.single().activityType).isEqualTo(ActivityTypeWithEventName.USER_TASK_CREATE) + assertThat((processLinks.single() as TestProcessLink).someValue).isEqualTo("imported value") + } + + private fun importRequest(content: String) = ImportRequest(FILE_NAME, content.toByteArray(Charsets.UTF_8)) + + private fun processDefinitionId(): String = + requireNotNull(repositoryService.findLatestProcessDefinition(PROCESS_DEFINITION_KEY)).id + + private fun json(activityId: String, someValue: String) = """ + [ + { + "activityId": "$activityId", + "activityType": "${ActivityTypeWithEventName.USER_TASK_CREATE.value}", + "processLinkType": "test", + "someValue": "$someValue" + } + ] + """.trimIndent() + + private companion object { + const val PROCESS_DEFINITION_KEY = "test-system-process" + const val FILE_NAME = "/global/process-link/$PROCESS_DEFINITION_KEY.process-link.json" + } +} diff --git a/backend/process-link/src/test/kotlin/com/ritense/processlink/importer/GlobalProcessLinkImporterTest.kt b/backend/process-link/src/test/kotlin/com/ritense/processlink/importer/GlobalProcessLinkImporterTest.kt new file mode 100644 index 0000000000..3b19a1fd63 --- /dev/null +++ b/backend/process-link/src/test/kotlin/com/ritense/processlink/importer/GlobalProcessLinkImporterTest.kt @@ -0,0 +1,170 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.ritense.processlink.importer + +import com.ritense.importer.ImportRequest +import com.ritense.importer.ValtimoImportTypes.Companion.GLOBAL_PROCESS_DEFINITION +import com.ritense.processdocument.service.ProcessDefinitionCaseDefinitionService +import com.ritense.processlink.domain.ActivityTypeWithEventName +import com.ritense.processlink.domain.ProcessLink +import com.ritense.processlink.importer.ProcessLinkImporterTest.TestProcessLinkDeployDto +import com.ritense.processlink.mapper.ProcessLinkMapper +import com.ritense.processlink.service.ProcessLinkService +import com.ritense.valtimo.contract.json.MapperSingleton +import com.ritense.valtimo.operaton.domain.OperatonProcessDefinition +import com.ritense.valtimo.operaton.service.OperatonRepositoryService +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.mockito.Mock +import org.mockito.Mockito +import org.mockito.junit.jupiter.MockitoExtension +import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.springframework.context.ApplicationEventPublisher +import java.util.UUID + +@ExtendWith(MockitoExtension::class) +class GlobalProcessLinkImporterTest { + + @Mock + lateinit var processLinkService: ProcessLinkService + + @Mock + lateinit var repositoryService: OperatonRepositoryService + + @Mock + lateinit var processDefinitionCaseDefinitionService: ProcessDefinitionCaseDefinitionService + + @Mock + lateinit var applicationEventPublisher: ApplicationEventPublisher + + private lateinit var importer: GlobalProcessLinkImporter + + private val objectMapper = MapperSingleton.get().also { + it.registerSubtypes(TestProcessLinkDeployDto::class.java) + } + + @BeforeEach + fun before() { + importer = GlobalProcessLinkImporter( + processLinkService, + repositoryService, + processDefinitionCaseDefinitionService, + objectMapper, + emptyList(), + applicationEventPublisher, + ) + } + + @Test + fun `should be of type 'globalprocesslink'`() { + assertThat(importer.type()).isEqualTo("globalprocesslink") + } + + @Test + fun `should depend on 'globalprocessdefinition' type`() { + whenever(processLinkService.getImporterDependsOnTypes()).thenReturn(setOf("x")) + + assertThat(importer.dependsOn()).isEqualTo(setOf(GLOBAL_PROCESS_DEFINITION, "x")) + } + + @Test + fun `should support global processlink fileName only`() { + assertThat(importer.supports(FILENAME)).isTrue() + assertThat(importer.supports("/process-link/my.process-link.json")).isFalse() + } + + @Test + fun `should not be part of a case definition`() { + assertThat(importer.partOfCaseDefinition()).isFalse() + } + + @Test + fun `import deletes process links that are not in the imported file`() { + val staleLink = processLink("Task_stale", ActivityTypeWithEventName.SERVICE_TASK_START) + val keptLink = processLink("Task_1", ActivityTypeWithEventName.SERVICE_TASK_START) + mockProcessDefinition(listOf(staleLink, keptLink)) + + importer.import(ImportRequest(FILENAME, singleLinkFor("Task_1").toByteArray())) + + verify(processLinkService).deleteProcessLink(staleLink.id) + verify(processLinkService, never()).deleteProcessLink(keptLink.id) + } + + @Test + fun `import keeps a process link when the same activity is in the imported file`() { + val keptLink = processLink("Task_1", ActivityTypeWithEventName.SERVICE_TASK_START) + mockProcessDefinition(listOf(keptLink)) + + importer.import(ImportRequest(FILENAME, singleLinkFor("Task_1").toByteArray())) + + verify(processLinkService, never()).deleteProcessLink(any()) + } + + @Test + fun `import deletes a process link of the same activity with another activity type`() { + val otherActivityTypeLink = processLink("Task_1", ActivityTypeWithEventName.USER_TASK_CREATE) + mockProcessDefinition(listOf(otherActivityTypeLink)) + + importer.import(ImportRequest(FILENAME, singleLinkFor("Task_1").toByteArray())) + + verify(processLinkService).deleteProcessLink(otherActivityTypeLink.id) + } + + private fun mockProcessDefinition(existingLinks: List) { + val processDefinition = mock() + whenever(processDefinition.id).thenReturn(PROCESS_DEFINITION_ID) + whenever(repositoryService.findLatestProcessDefinition(PROCESS_DEFINITION_KEY)) + .thenReturn(processDefinition) + whenever(processLinkService.getProcessLinks(PROCESS_DEFINITION_ID)).thenReturn(existingLinks) + whenever(processLinkService.getProcessLinkMapper("test-type")) + .thenReturn(ProcessLinkImporterTest.TestMapper()) + doReturn(mock()).whenever(processLinkService).createProcessLink(any(), anyOrNull()) + } + + private fun processLink(activityId: String, activityType: ActivityTypeWithEventName): ProcessLink { + val processLink = mock() + // The id is only read when the link is deleted + Mockito.lenient().`when`(processLink.id).thenReturn(UUID.randomUUID()) + whenever(processLink.activityId).thenReturn(activityId) + whenever(processLink.activityType).thenReturn(activityType) + return processLink + } + + private fun singleLinkFor(activityId: String) = """ + [ + { + "activityId": "$activityId", + "activityType": "bpmn:ServiceTask:start", + "processLinkType": "test-type" + } + ] + """.trimIndent() + + private companion object { + const val PROCESS_DEFINITION_KEY = "my" + const val PROCESS_DEFINITION_ID = "pd-1" + const val FILENAME = "/global/process-link/$PROCESS_DEFINITION_KEY.process-link.json" + } +} diff --git a/backend/process-link/src/test/kotlin/com/ritense/processlink/service/ProcessDefinitionImportPreviewServiceIntTest.kt b/backend/process-link/src/test/kotlin/com/ritense/processlink/service/ProcessDefinitionImportPreviewServiceIntTest.kt new file mode 100644 index 0000000000..874c9965cd --- /dev/null +++ b/backend/process-link/src/test/kotlin/com/ritense/processlink/service/ProcessDefinitionImportPreviewServiceIntTest.kt @@ -0,0 +1,177 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.ritense.processlink.service + +import com.ritense.authorization.AuthorizationContext.Companion.runWithoutAuthorization +import com.ritense.exporter.ExportService +import com.ritense.exporter.request.GlobalProcessDefinitionExportRequest +import com.ritense.processlink.BaseIntegrationTest +import com.ritense.processlink.web.rest.dto.MissingReferenceType +import com.ritense.valtimo.contract.config.ValtimoProperties +import com.ritense.valtimo.domain.processdefinition.ProcessDefinitionProperties +import com.ritense.valtimo.operaton.service.OperatonRepositoryService +import com.ritense.valtimo.processdefinition.repository.ProcessDefinitionPropertiesRepository +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.transaction.annotation.Transactional +import java.io.ByteArrayInputStream + +/** + * Previews the package produced by exporting the autodeployed `test-system-process`. + */ +@Transactional +class ProcessDefinitionImportPreviewServiceIntTest @Autowired constructor( + private val exportService: ExportService, + private val processDefinitionImportPreviewService: ProcessDefinitionImportPreviewService, + private val processDefinitionPropertiesRepository: ProcessDefinitionPropertiesRepository, + private val operatonRepositoryService: OperatonRepositoryService, + private val valtimoProperties: ValtimoProperties, +) : BaseIntegrationTest() { + + @Test + fun `should report the process of the package`(): Unit = runWithoutAuthorization { + val preview = processDefinitionImportPreviewService.preview(exportedPackage()) + + assertThat(preview.processDefinitionKeys).containsExactly(PROCESS_DEFINITION_KEY) + assertThat(preview.missingReferences).isEmpty() + assertThat(preview.canImport).isTrue() + } + + @Test + fun `should report that the process of the package already exists here`(): Unit = runWithoutAuthorization { + val preview = processDefinitionImportPreviewService.preview(exportedPackage()) + + assertThat(preview.existingProcessDefinitionKeys).containsExactly(PROCESS_DEFINITION_KEY) + } + + @Test + fun `should refuse a package for a process that is a read only system process here`(): Unit = + runWithoutAuthorization { + givenSystemProcess() + + val preview = processDefinitionImportPreviewService.preview(exportedPackage()) + + assertThat(preview.missingReferences).hasSize(1) + assertThat(preview.missingReferences.single().type) + .isEqualTo(MissingReferenceType.READ_ONLY_SYSTEM_PROCESS) + assertThat(preview.missingReferences.single().reference).isEqualTo(PROCESS_DEFINITION_KEY) + assertThat(preview.canImport).isFalse() + } + + @Test + fun `should allow a package for a system process that may be updated`(): Unit = runWithoutAuthorization { + givenSystemProcess() + valtimoProperties.process.isSystemProcessUpdatable = true + + try { + val preview = processDefinitionImportPreviewService.preview(exportedPackage()) + + assertThat(preview.missingReferences).isEmpty() + assertThat(preview.canImport).isTrue() + } finally { + valtimoProperties.process.isSystemProcessUpdatable = false + } + } + + @Test + fun `should allow a package for a process that does not exist here yet`(): Unit = runWithoutAuthorization { + // There are no process definition properties for an unknown process, which isReadOnly cannot handle + val preview = processDefinitionImportPreviewService.preview( + zipOf("config/global/bpmn/does-not-exist-here.bpmn" to bpmnWithCallActivity("does-not-exist-here", null)) + ) + + assertThat(preview.processDefinitionKeys).containsExactly("does-not-exist-here") + assertThat(preview.existingProcessDefinitionKeys).isEmpty() + assertThat(preview.missingReferences).isEmpty() + assertThat(preview.canImport).isTrue() + } + + @Test + fun `should report a called sub-process that is not deployed here`(): Unit = runWithoutAuthorization { + val preview = processDefinitionImportPreviewService.preview( + zipOf( + "config/global/bpmn/calling-process.bpmn" to + bpmnWithCallActivity("calling-process", "not-deployed-process") + ) + ) + + assertThat(preview.missingReferences).hasSize(1) + assertThat(preview.missingReferences.single().type).isEqualTo(MissingReferenceType.SUB_PROCESS) + assertThat(preview.missingReferences.single().reference).isEqualTo("not-deployed-process") + // A missing sub-process does not stop the import + assertThat(preview.canImport).isTrue() + } + + @Test + fun `should not report a called sub-process that is deployed here`(): Unit = runWithoutAuthorization { + val preview = processDefinitionImportPreviewService.preview( + zipOf( + "config/global/bpmn/calling-process.bpmn" to + bpmnWithCallActivity("calling-process", PROCESS_DEFINITION_KEY) + ) + ) + + assertThat(preview.missingReferences).isEmpty() + } + + private fun givenSystemProcess() { + processDefinitionPropertiesRepository.save( + ProcessDefinitionProperties(PROCESS_DEFINITION_KEY, true) + ) + } + + private fun exportedPackage(): ByteArrayInputStream { + val processDefinitionId = + requireNotNull(operatonRepositoryService.findLatestProcessDefinition(PROCESS_DEFINITION_KEY)).id + val outputStream = exportService.export(GlobalProcessDefinitionExportRequest(processDefinitionId)) + return ByteArrayInputStream(outputStream.toByteArray()) + } + + private fun bpmnWithCallActivity(processDefinitionKey: String, calledElement: String?): String { + val callActivity = calledElement?.let { + """""" + } ?: "" + + return """ + + + + + $callActivity + + + """.trimIndent() + } + + private fun zipOf(vararg entries: Pair): ByteArrayInputStream { + val outputStream = java.io.ByteArrayOutputStream() + java.util.zip.ZipOutputStream(outputStream).use { zos -> + entries.forEach { (path, content) -> + zos.putNextEntry(java.util.zip.ZipEntry(path)) + zos.write(content.toByteArray()) + zos.closeEntry() + } + } + return ByteArrayInputStream(outputStream.toByteArray()) + } + + private companion object { + const val PROCESS_DEFINITION_KEY = "test-system-process" + } +} diff --git a/backend/process-link/src/test/kotlin/com/ritense/processlink/service/ProcessDefinitionImportPreviewServiceTest.kt b/backend/process-link/src/test/kotlin/com/ritense/processlink/service/ProcessDefinitionImportPreviewServiceTest.kt new file mode 100644 index 0000000000..d965c97773 --- /dev/null +++ b/backend/process-link/src/test/kotlin/com/ritense/processlink/service/ProcessDefinitionImportPreviewServiceTest.kt @@ -0,0 +1,325 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.ritense.processlink.service + +import com.ritense.importer.exception.ImportServiceException +import com.ritense.processlink.domain.ActivityTypeWithEventName +import com.ritense.processlink.importer.ProcessLinkImporterTest.TestProcessLinkDeployDto +import com.ritense.processlink.mapper.ProcessLinkMapper +import com.ritense.processlink.web.rest.dto.MissingReferenceDto +import com.ritense.processlink.web.rest.dto.MissingReferenceType +import com.ritense.valtimo.contract.importer.ImportPreviewContribution +import com.ritense.valtimo.contract.importer.ImportPreviewContributor +import com.ritense.valtimo.contract.json.MapperSingleton +import com.ritense.valtimo.domain.processdefinition.ProcessDefinitionProperties +import com.ritense.valtimo.operaton.domain.OperatonDecisionDefinition +import com.ritense.valtimo.operaton.domain.OperatonProcessDefinition +import com.ritense.valtimo.operaton.service.OperatonRepositoryService +import com.ritense.valtimo.service.ProcessPropertyService +import org.assertj.core.api.Assertions.assertThat +import org.assertj.core.api.Assertions.assertThatThrownBy +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.mockito.Mock +import org.mockito.junit.jupiter.MockitoExtension +import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import org.springframework.data.jpa.domain.Specification +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream +import java.util.UUID +import java.util.zip.ZipEntry +import java.util.zip.ZipOutputStream + +@ExtendWith(MockitoExtension::class) +class ProcessDefinitionImportPreviewServiceTest { + + @Mock + lateinit var processLinkService: ProcessLinkService + + @Mock + lateinit var repositoryService: OperatonRepositoryService + + @Mock + lateinit var processPropertyService: ProcessPropertyService + + private val objectMapper = MapperSingleton.get().also { + it.registerSubtypes(TestProcessLinkDeployDto::class.java) + } + + private lateinit var service: ProcessDefinitionImportPreviewService + + @BeforeEach + fun before() { + service = ProcessDefinitionImportPreviewService( + objectMapper, + emptyList(), + processLinkService, + repositoryService, + processPropertyService, + ) + } + + @Test + fun `should report the process definition keys in the package`() { + val preview = service.preview(zipOf(BPMN_PATH to bpmn())) + + assertThat(preview.processDefinitionKeys).containsExactly(PROCESS_DEFINITION_KEY) + assertThat(preview.canImport).isTrue() + } + + @Test + fun `should report a process of the package that already exists here`() { + whenever(repositoryService.findLatestProcessDefinition(PROCESS_DEFINITION_KEY)) + .thenReturn(mock()) + + val preview = service.preview(zipOf(BPMN_PATH to bpmn())) + + assertThat(preview.existingProcessDefinitionKeys).containsExactly(PROCESS_DEFINITION_KEY) + } + + @Test + fun `should not report a process of the package that does not exist here`() { + val preview = service.preview(zipOf(BPMN_PATH to bpmn())) + + assertThat(preview.existingProcessDefinitionKeys).isEmpty() + } + + @Test + fun `should fail when the package contains no process definition`() { + assertThatThrownBy { service.preview(zipOf("config/global/role/global.role.json" to "[]")) } + .isInstanceOf(ImportServiceException::class.java) + .hasMessageContaining("No process definition found") + } + + @Test + fun `should fail when the file is not a zip`() { + assertThatThrownBy { service.preview(ByteArrayInputStream("not a zip".toByteArray())) } + .isInstanceOf(ImportServiceException::class.java) + } + + @Test + fun `should report a plugin configuration contributed by a preview contributor`() { + val pluginConfigurationId = UUID.randomUUID() + service = ProcessDefinitionImportPreviewService( + objectMapper, + listOf(contributorReturning(pluginConfigurationId)), + processLinkService, + repositoryService, + processPropertyService, + ) + + val preview = service.preview(zipOf(BPMN_PATH to bpmn())) + + val pluginConfiguration = preview.pluginConfigurations.single() + assertThat(pluginConfiguration.pluginConfigurationId).isEqualTo(pluginConfigurationId) + assertThat(pluginConfiguration.existsInTargetEnvironment).isFalse() + } + + @Test + fun `should report a called sub-process that is not deployed`() { + val preview = service.preview(zipOf(BPMN_PATH to bpmn(callActivityCalling = "other-process"))) + + assertThat(preview.missingReferences).containsExactly( + MissingReferenceDto( + type = MissingReferenceType.SUB_PROCESS, + reference = "other-process", + activityId = "CallActivity_1", + processDefinitionKey = PROCESS_DEFINITION_KEY, + ) + ) + // A missing sub-process does not stop the import + assertThat(preview.canImport).isTrue() + } + + @Test + fun `should not report a called sub-process that is deployed`() { + whenever(repositoryService.findLatestProcessDefinition(PROCESS_DEFINITION_KEY)).thenReturn(null) + whenever(repositoryService.findLatestProcessDefinition("other-process")) + .thenReturn(mock()) + + val preview = service.preview(zipOf(BPMN_PATH to bpmn(callActivityCalling = "other-process"))) + + assertThat(preview.missingReferences).isEmpty() + } + + @Test + fun `should not report a called sub-process that is part of the same package`() { + val preview = service.preview( + zipOf( + BPMN_PATH to bpmn(callActivityCalling = "other-process"), + "config/global/bpmn/other-process.bpmn" to bpmn(key = "other-process"), + ) + ) + + assertThat(preview.missingReferences).isEmpty() + } + + @Test + fun `should report a referenced decision definition that is not deployed`() { + val preview = service.preview(zipOf(BPMN_PATH to bpmn(decisionRef = "my-decision"))) + + assertThat(preview.missingReferences.single().type) + .isEqualTo(MissingReferenceType.DECISION_DEFINITION) + assertThat(preview.missingReferences.single().reference).isEqualTo("my-decision") + } + + @Test + fun `should not report a referenced decision definition that is deployed`() { + whenever(repositoryService.findDecisionDefinition(any>())) + .thenReturn(mock()) + + val preview = service.preview(zipOf(BPMN_PATH to bpmn(decisionRef = "my-decision"))) + + assertThat(preview.missingReferences).isEmpty() + } + + @Test + fun `should report a missing reference of a process link and block the import`() { + val mapper = mock() + whenever(mapper.getMissingReference(any(), anyOrNull())).thenReturn( + MissingReferenceDto(type = MissingReferenceType.FORM, reference = "my-form") + ) + whenever(processLinkService.getProcessLinkMapper("test-type")).thenReturn(mapper) + + val preview = service.preview( + zipOf( + BPMN_PATH to bpmn(), + PROCESS_LINK_PATH to processLinkJson(), + ) + ) + + assertThat(preview.missingReferences).containsExactly( + MissingReferenceDto( + type = MissingReferenceType.FORM, + reference = "my-form", + processDefinitionKey = PROCESS_DEFINITION_KEY, + ) + ) + assertThat(preview.canImport).isFalse() + } + + @Test + fun `should block the import when the process is a read only system process here`() { + mockDeployedSystemProcess(readOnly = true) + + val preview = service.preview(zipOf(BPMN_PATH to bpmn())) + + assertThat(preview.missingReferences.single().type) + .isEqualTo(MissingReferenceType.READ_ONLY_SYSTEM_PROCESS) + assertThat(preview.canImport).isFalse() + } + + @Test + fun `should allow the import when a system process here may be updated`() { + mockDeployedSystemProcess(readOnly = false) + + val preview = service.preview(zipOf(BPMN_PATH to bpmn())) + + assertThat(preview.missingReferences).isEmpty() + assertThat(preview.canImport).isTrue() + } + + @Test + fun `should allow the import when the process does not exist here yet`() { + // No properties exist for an unknown process, which isReadOnly cannot handle + whenever(repositoryService.findLatestProcessDefinition(PROCESS_DEFINITION_KEY)).thenReturn(null) + + val preview = service.preview(zipOf(BPMN_PATH to bpmn())) + + assertThat(preview.missingReferences).isEmpty() + assertThat(preview.canImport).isTrue() + } + + private fun mockDeployedSystemProcess(readOnly: Boolean) { + whenever(repositoryService.findLatestProcessDefinition(PROCESS_DEFINITION_KEY)) + .thenReturn(mock()) + whenever(processPropertyService.findByProcessDefinitionKey(PROCESS_DEFINITION_KEY)) + .thenReturn(mock()) + whenever(processPropertyService.isReadOnly(PROCESS_DEFINITION_KEY)).thenReturn(readOnly) + } + + private fun contributorReturning(pluginConfigurationId: UUID) = ImportPreviewContributor { + listOf( + ImportPreviewContribution( + pluginConfigurationId = pluginConfigurationId, + pluginDefinitionKey = "my-plugin", + pluginActionDefinitionKey = "my-action", + processDefinitionKey = PROCESS_DEFINITION_KEY, + activityId = "Task_1", + existsInTargetEnvironment = false, + ) + ) + } + + private fun processLinkJson() = """ + [ + { + "activityId": "Task_1", + "activityType": "${ActivityTypeWithEventName.SERVICE_TASK_START.value}", + "processLinkType": "test-type" + } + ] + """.trimIndent() + + private fun bpmn( + key: String = PROCESS_DEFINITION_KEY, + callActivityCalling: String? = null, + decisionRef: String? = null, + ): String { + val callActivity = callActivityCalling?.let { + """""" + } ?: "" + val businessRuleTask = decisionRef?.let { + """""" + } ?: "" + + return """ + + + + + $callActivity + $businessRuleTask + + + """.trimIndent() + } + + private fun zipOf(vararg entries: Pair): ByteArrayInputStream { + val outputStream = ByteArrayOutputStream() + ZipOutputStream(outputStream).use { zos -> + entries.forEach { (path, content) -> + zos.putNextEntry(ZipEntry(path)) + zos.write(content.toByteArray()) + zos.closeEntry() + } + } + return ByteArrayInputStream(outputStream.toByteArray()) + } + + private companion object { + const val PROCESS_DEFINITION_KEY = "my-process" + const val BPMN_PATH = "config/global/bpmn/$PROCESS_DEFINITION_KEY.bpmn" + const val PROCESS_LINK_PATH = "config/global/process-link/$PROCESS_DEFINITION_KEY.process-link.json" + } +} diff --git a/backend/process-link/src/test/kotlin/com/ritense/processlink/web/rest/ProcessLinkResourceTest.kt b/backend/process-link/src/test/kotlin/com/ritense/processlink/web/rest/ProcessLinkResourceTest.kt index 94c3400ef3..e2838b1dc5 100644 --- a/backend/process-link/src/test/kotlin/com/ritense/processlink/web/rest/ProcessLinkResourceTest.kt +++ b/backend/process-link/src/test/kotlin/com/ritense/processlink/web/rest/ProcessLinkResourceTest.kt @@ -17,6 +17,10 @@ package com.ritense.processlink.web.rest import com.fasterxml.jackson.databind.ObjectMapper +import com.ritense.exporter.ExportService +import com.ritense.exporter.request.GlobalProcessDefinitionExportRequest +import com.ritense.importer.ImportService +import com.ritense.importer.exception.ImportServiceException import com.ritense.processdocument.service.ProcessDefinitionCaseDefinitionService import com.ritense.processlink.domain.ActivityTypeWithEventName import com.ritense.processlink.domain.TestProcessLink @@ -24,22 +28,29 @@ import com.ritense.processlink.domain.TestProcessLinkCreateRequestDto import com.ritense.processlink.domain.TestProcessLinkMapper import com.ritense.processlink.domain.TestProcessLinkUpdateRequestDto import com.ritense.processlink.mapper.ProcessLinkMapper +import com.ritense.processlink.service.ProcessDefinitionImportPreviewService import com.ritense.processlink.service.ProcessDeploymentService import com.ritense.processlink.service.ProcessLinkService +import com.ritense.processlink.web.rest.dto.MissingReferenceDto +import com.ritense.processlink.web.rest.dto.MissingReferenceType +import com.ritense.processlink.web.rest.dto.ProcessDefinitionImportPreviewResponseDto import com.ritense.processlink.validation.ProcessDefinitionValidator import com.ritense.valtimo.contract.json.MapperSingleton import com.ritense.valtimo.operaton.domain.OperatonProcessDefinition import com.ritense.valtimo.service.OperatonProcessService import com.ritense.valtimo.service.ProcessPropertyService import org.operaton.bpm.engine.RepositoryService +import org.hamcrest.Matchers.matchesRegex import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test import org.mockito.kotlin.any import org.mockito.kotlin.anyOrNull import org.mockito.kotlin.mock +import org.mockito.kotlin.never import org.mockito.kotlin.verify import org.mockito.kotlin.whenever import org.springframework.http.MediaType +import org.springframework.http.converter.ByteArrayHttpMessageConverter import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter import org.springframework.test.web.servlet.MockMvc import org.springframework.http.HttpMethod @@ -50,9 +61,11 @@ import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multi import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put import org.springframework.test.web.servlet.result.MockMvcResultHandlers.print +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.header import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status import org.springframework.test.web.servlet.setup.MockMvcBuilders +import java.io.ByteArrayOutputStream import java.nio.charset.StandardCharsets import java.util.UUID @@ -69,6 +82,9 @@ internal class ProcessLinkResourceTest { lateinit var processDeploymentService: ProcessDeploymentService lateinit var processDefinitionValidator: ProcessDefinitionValidator lateinit var processPropertyService: ProcessPropertyService + lateinit var exportService: ExportService + lateinit var importService: ImportService + lateinit var processDefinitionImportPreviewService: ProcessDefinitionImportPreviewService @BeforeEach fun init() { @@ -80,6 +96,9 @@ internal class ProcessLinkResourceTest { processDeploymentService = mock() processDefinitionValidator = mock() processPropertyService = mock() + exportService = mock() + importService = mock() + processDefinitionImportPreviewService = mock() processLinkMappers = listOf(TestProcessLinkMapper(objectMapper)) processLinkResource = ProcessLinkResource( processLinkService, @@ -89,7 +108,11 @@ internal class ProcessLinkResourceTest { repositoryService, processDeploymentService, processDefinitionValidator, - processPropertyService + processPropertyService, + exportService, + importService, + processDefinitionImportPreviewService, + objectMapper, ) val mappingJackson2HttpMessageConverter = MappingJackson2HttpMessageConverter() @@ -97,7 +120,8 @@ internal class ProcessLinkResourceTest { mockMvc = MockMvcBuilders .standaloneSetup(processLinkResource) - .setMessageConverters(mappingJackson2HttpMessageConverter) + // The export endpoint responds with a zip, which needs the byte array converter + .setMessageConverters(mappingJackson2HttpMessageConverter, ByteArrayHttpMessageConverter()) .build() } @@ -304,6 +328,93 @@ internal class ProcessLinkResourceTest { verify(processDeploymentService).deployProcessDefinitionAndProcessLinks(anyOrNull(), anyOrNull(), any(), anyOrNull()) } + @Test + fun `should export a process definition as a zip`() { + whenever(camdunaProcessService.getProcessDefinitionById("pid")) + .thenReturn(operatonProcessDefinition("pid", "my-process", "My process")) + whenever(exportService.export(GlobalProcessDefinitionExportRequest("pid"))) + .thenReturn(ByteArrayOutputStream().apply { write("zip-content".toByteArray()) }) + + mockMvc.perform(get("/api/management/v1/process-definition/pid/export")) + .andDo(print()) + .andExpect(status().isOk) + .andExpect( + header().string( + "Content-Disposition", + matchesRegex("attachment;filename=my-process_v1_.*\\.process\\.zip") + ) + ) + } + + @Test + fun `should preview a process definition import`() { + whenever(processDefinitionImportPreviewService.preview(any())) + .thenReturn(ProcessDefinitionImportPreviewResponseDto(processDefinitionKeys = listOf("my-process"))) + + mockMvc.perform( + multipart("/api/management/v1/process-definition/import/preview") + .file(MockMultipartFile("file", "my-process.process.zip", null, "zip".toByteArray())) + ) + .andDo(print()) + .andExpect(status().isOk) + .andExpect(jsonPath("$.processDefinitionKeys.[0]").value("my-process")) + .andExpect(jsonPath("$.canImport").value(true)) + } + + @Test + fun `should return a bad request when the import preview fails`() { + whenever(processDefinitionImportPreviewService.preview(any())) + .thenThrow(ImportServiceException("Archive was empty or not a zip")) + + mockMvc.perform( + multipart("/api/management/v1/process-definition/import/preview") + .file(MockMultipartFile("file", "invalid.zip", null, "invalid".toByteArray())) + ) + .andDo(print()) + .andExpect(status().isBadRequest) + } + + @Test + fun `should import a process definition`() { + whenever(processDefinitionImportPreviewService.preview(any())) + .thenReturn(ProcessDefinitionImportPreviewResponseDto(processDefinitionKeys = listOf("my-process"))) + + mockMvc.perform( + multipart("/api/management/v1/process-definition/import") + .file(MockMultipartFile("file", "my-process.process.zip", null, "zip".toByteArray())) + ) + .andDo(print()) + .andExpect(status().isOk) + .andExpect(jsonPath("$.processDefinitionKeys.[0]").value("my-process")) + + verify(importService).importGlobal(any(), anyOrNull()) + } + + @Test + fun `should refuse to import when a reference blocks the import`() { + val missingReference = MissingReferenceDto( + type = MissingReferenceType.READ_ONLY_SYSTEM_PROCESS, + reference = "my-process", + ) + whenever(processDefinitionImportPreviewService.preview(any())).thenReturn( + ProcessDefinitionImportPreviewResponseDto( + processDefinitionKeys = listOf("my-process"), + missingReferences = listOf(missingReference), + ) + ) + + mockMvc.perform( + multipart("/api/management/v1/process-definition/import") + .file(MockMultipartFile("file", "my-process.process.zip", null, "zip".toByteArray())) + ) + .andDo(print()) + .andExpect(status().isBadRequest) + .andExpect(jsonPath("$.missingReferences.[0].type").value("READ_ONLY_SYSTEM_PROCESS")) + .andExpect(jsonPath("$.missingReferences.[0].reference").value("my-process")) + + verify(importService, never()).importGlobal(any(), anyOrNull()) + } + private fun operatonProcessDefinition(id: String, key: String, name: String?) = OperatonProcessDefinition( id = id, revision = 1, diff --git a/documentation/release-notes/13.x.x/13.40.0/README.md b/documentation/release-notes/13.x.x/13.40.0/README.md index 476e99e8ec..8383639983 100644 --- a/documentation/release-notes/13.x.x/13.40.0/README.md +++ b/documentation/release-notes/13.x.x/13.40.0/README.md @@ -6,16 +6,52 @@ ## New Features -* **New feature title** +* **Exporting and importing a process with its process links** - New feature explanation. + A process that is not part of a case can now be exported together with its process links, and + imported on another environment. Use *Export with process links* in the menu of the process to + download a package, and upload that package on the target environment. Changes that were built and + tested on one environment therefore no longer have to be reconnected by hand elsewhere. + + During the import you can point every plugin link in the package at the plugin configuration of + the target environment, so links keep working on an environment that uses different + configurations. When the process already exists on the environment, the import asks for + confirmation before replacing it, just like uploading a single BPMN file does. + + Uploading a single BPMN file keeps working as before. ## Enhancements -* **New enhancement title** +* **An import summary shows what is still missing** + + After importing, a summary shows which items the process refers to that are not present on this + environment: forms, form flow definitions, decision tables and called sub-processes. Those items + are deliberately not part of the package, because they can be shared with other processes, and are + imported separately. Only references that could be determined are shown. + + When the process refers to a form that does not exist on this environment, the import is refused + beforehand and the missing form is named, so no half-imported process is left behind. + +* **A process that is managed by configuration cannot be overwritten by an import** - New enhancement explanation. + Importing a package for a process that exists on this environment as a system process that may not + be changed is refused, with an explanation. Such a process can still be exported. + +* **Process links from the application configuration are leading** + + Process links that are supplied with the application configuration are now leading: a link that is + not in that configuration is removed when the application starts. This keeps environments that are + managed through configuration identical to that configuration. ## Bugfixes -* New bugfix. +* **The exported file is named after the process** + + Exporting a process definition from the process editor produced a file named `diagram.bpmn`. The + file is now named after the process, so it is clear which process was exported. + +* **Selecting a file to upload a process definition filters on the supported file types again** + + The file dialog offered every file type instead of only the supported ones, and dragging a file + onto the upload area did nothing. Both work again, for BPMN files as well as exported process + packages. diff --git a/frontend/projects/valtimo/plugin/src/lib/components/plugin-configuration-mapping/plugin-configuration-mapping.component.html b/frontend/projects/valtimo/plugin/src/lib/components/plugin-configuration-mapping/plugin-configuration-mapping.component.html new file mode 100644 index 0000000000..f21f4c9fd8 --- /dev/null +++ b/frontend/projects/valtimo/plugin/src/lib/components/plugin-configuration-mapping/plugin-configuration-mapping.component.html @@ -0,0 +1,82 @@ + + +
+ + + +
+ {{ 'pluginConfigurationMapping.source' | translate }} + + {{ 'pluginConfigurationMapping.target' | translate }} +
+ +
+
{{ row.pluginDefinitionTitle }}
+ + + + + + + + + +
+ {{ 'pluginConfigurationMapping.notInstalled' | translate }} +
+ +
+ {{ 'pluginConfigurationMapping.noConfigurations' | translate }} +
+
+
+
diff --git a/frontend/projects/valtimo/plugin/src/lib/components/plugin-configuration-mapping/plugin-configuration-mapping.component.scss b/frontend/projects/valtimo/plugin/src/lib/components/plugin-configuration-mapping/plugin-configuration-mapping.component.scss new file mode 100644 index 0000000000..8358caea07 --- /dev/null +++ b/frontend/projects/valtimo/plugin/src/lib/components/plugin-configuration-mapping/plugin-configuration-mapping.component.scss @@ -0,0 +1,72 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +.plugin-configuration-mapping { + display: flex; + flex-direction: column; + gap: 16px; + + &__header { + display: grid; + grid-template-columns: 1fr 24px 1fr; + gap: 8px; + align-items: center; + + span { + font-size: 0.75rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.32px; + color: var(--cds-text-secondary); + } + } + + &__row { + display: grid; + grid-template-columns: 1fr 24px 1fr; + gap: 8px; + align-items: center; + padding: 12px; + background-color: var(--cds-layer); + } + + &__source { + font-size: 0.875rem; + font-weight: 400; + padding: 0 1rem; + height: 2.5rem; + display: flex; + align-items: center; + } + + &__arrow { + fill: var(--cds-text-secondary); + justify-self: center; + } + + &__unavailable { + font-size: 0.875rem; + color: var(--cds-text-disabled); + padding: 0 1rem; + height: 2.5rem; + display: flex; + align-items: center; + + &--error { + color: var(--cds-text-error); + } + } +} diff --git a/frontend/projects/valtimo/plugin/src/lib/components/plugin-configuration-mapping/plugin-configuration-mapping.component.spec.ts b/frontend/projects/valtimo/plugin/src/lib/components/plugin-configuration-mapping/plugin-configuration-mapping.component.spec.ts new file mode 100644 index 0000000000..f8b53d771d --- /dev/null +++ b/frontend/projects/valtimo/plugin/src/lib/components/plugin-configuration-mapping/plugin-configuration-mapping.component.spec.ts @@ -0,0 +1,142 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import {ComponentFixture, TestBed, waitForAsync} from '@angular/core/testing'; +import {TranslateModule, TranslateService} from '@ngx-translate/core'; +import {of} from 'rxjs'; +import {PluginManagementService} from '../../services/plugin-management.service'; +import {PluginTranslationService} from '../../services/plugin-translation.service'; +import {PluginConfigurationMappingComponent} from './plugin-configuration-mapping.component'; + +describe('PluginConfigurationMappingComponent', () => { + let component: PluginConfigurationMappingComponent; + let fixture: ComponentFixture; + let pluginManagementService: jasmine.SpyObj; + + const SOURCE_ID = '5474fe57-532a-4050-8d89-32e62ca3e895'; + const TARGET_ID = '3079d6fe-42e3-4f8f-a9db-52ce2507b7ee'; + + beforeEach(waitForAsync(() => { + pluginManagementService = jasmine.createSpyObj('PluginManagementService', [ + 'getPluginDefinitions', + 'getPluginConfigurationsByPluginDefinitionKey', + ]); + + TestBed.configureTestingModule({ + imports: [PluginConfigurationMappingComponent, TranslateModule.forRoot()], + providers: [ + {provide: PluginManagementService, useValue: pluginManagementService}, + { + provide: PluginTranslationService, + useValue: {instant: (_key: string, pluginDefinitionKey: string) => pluginDefinitionKey}, + }, + TranslateService, + ], + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(PluginConfigurationMappingComponent); + component = fixture.componentInstance; + }); + + it('should preselect the source configuration when it exists on this environment', () => { + pluginManagementService.getPluginDefinitions.and.returnValue(of([{key: 'my-plugin'}] as any)); + pluginManagementService.getPluginConfigurationsByPluginDefinitionKey.and.returnValue( + of([{id: SOURCE_ID, title: 'My configuration'}] as any) + ); + component.pluginConfigurations = [ + { + pluginConfigurationId: SOURCE_ID, + pluginDefinitionKey: 'my-plugin', + existsInTargetEnvironment: true, + }, + ]; + + fixture.detectChanges(); + + expect(component.rows$.value[0].status).toBe('available'); + expect(component.getMappings()).toEqual({[SOURCE_ID]: SOURCE_ID}); + }); + + it('should map the source configuration to the selected target configuration', () => { + pluginManagementService.getPluginDefinitions.and.returnValue(of([{key: 'my-plugin'}] as any)); + pluginManagementService.getPluginConfigurationsByPluginDefinitionKey.and.returnValue( + of([{id: TARGET_ID, title: 'Other configuration'}] as any) + ); + component.pluginConfigurations = [ + { + pluginConfigurationId: SOURCE_ID, + pluginDefinitionKey: 'my-plugin', + existsInTargetEnvironment: false, + }, + ]; + + fixture.detectChanges(); + component.form.get(SOURCE_ID)?.setValue(TARGET_ID); + + expect(component.getMappings()).toEqual({[SOURCE_ID]: TARGET_ID}); + }); + + it('should mark a configuration of a plugin that is not installed as not-installed', () => { + pluginManagementService.getPluginDefinitions.and.returnValue( + of([{key: 'other-plugin'}] as any) + ); + component.pluginConfigurations = [ + { + pluginConfigurationId: SOURCE_ID, + pluginDefinitionKey: 'my-plugin', + existsInTargetEnvironment: false, + }, + ]; + + fixture.detectChanges(); + + expect(component.rows$.value[0].status).toBe('not-installed'); + expect(component.getMappings()).toEqual({[SOURCE_ID]: null}); + }); + + it('should mark a plugin without configurations on this environment as no-configurations', () => { + pluginManagementService.getPluginDefinitions.and.returnValue(of([{key: 'my-plugin'}] as any)); + pluginManagementService.getPluginConfigurationsByPluginDefinitionKey.and.returnValue(of([])); + component.pluginConfigurations = [ + { + pluginConfigurationId: SOURCE_ID, + pluginDefinitionKey: 'my-plugin', + existsInTargetEnvironment: false, + }, + ]; + + fixture.detectChanges(); + + expect(component.rows$.value[0].status).toBe('no-configurations'); + }); + + it('should report unidentifiable plugins and not build a row for them', () => { + component.pluginConfigurations = [ + { + pluginConfigurationId: SOURCE_ID, + pluginDefinitionKey: null, + existsInTargetEnvironment: false, + }, + ]; + + fixture.detectChanges(); + + expect(component.hasUnidentifiablePlugins$.value).toBeTrue(); + expect(component.rows$.value).toEqual([]); + }); +}); diff --git a/frontend/projects/valtimo/plugin/src/lib/components/plugin-configuration-mapping/plugin-configuration-mapping.component.ts b/frontend/projects/valtimo/plugin/src/lib/components/plugin-configuration-mapping/plugin-configuration-mapping.component.ts new file mode 100644 index 0000000000..71dab1ab1b --- /dev/null +++ b/frontend/projects/valtimo/plugin/src/lib/components/plugin-configuration-mapping/plugin-configuration-mapping.component.ts @@ -0,0 +1,232 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import {CommonModule} from '@angular/common'; +import {ChangeDetectionStrategy, Component, Input, OnInit} from '@angular/core'; +import {FormBuilder, FormGroup, ReactiveFormsModule} from '@angular/forms'; +import {TranslateModule, TranslateService} from '@ngx-translate/core'; +import {ComboBoxModule, LayerModule, ListItem, NotificationModule} from 'carbon-components-angular'; +import {BehaviorSubject, forkJoin, Observable, take} from 'rxjs'; +import {PluginConfiguration} from '../../models'; +import {PluginManagementService} from '../../services/plugin-management.service'; +import {PluginTranslationService} from '../../services/plugin-translation.service'; + +type PluginMappingStatus = 'available' | 'no-configurations' | 'not-installed'; + +interface PluginConfigurationPreview { + pluginConfigurationId: string; + pluginDefinitionKey: string | null; + existsInTargetEnvironment: boolean; +} + +interface PluginMappingRow { + pluginDefinitionKey: string | null; + pluginDefinitionTitle: string; + sourcePluginConfigurationId: string; + existsInTargetEnvironment: boolean; + listItems: ListItem[]; + status: PluginMappingStatus; +} + +/** + * Lets the user point each plugin configuration referenced by an import at a configuration of this + * environment. + */ +@Component({ + selector: 'valtimo-plugin-configuration-mapping', + templateUrl: './plugin-configuration-mapping.component.html', + styleUrls: ['./plugin-configuration-mapping.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, + standalone: true, + imports: [ + CommonModule, + TranslateModule, + ReactiveFormsModule, + ComboBoxModule, + LayerModule, + NotificationModule, + ], +}) +export class PluginConfigurationMappingComponent implements OnInit { + @Input() public pluginConfigurations: PluginConfigurationPreview[] = []; + + public readonly form: FormGroup = this.formBuilder.group({}); + + public readonly rows$ = new BehaviorSubject([]); + public readonly hasUnidentifiablePlugins$ = new BehaviorSubject(false); + + constructor( + private readonly formBuilder: FormBuilder, + private readonly pluginManagementService: PluginManagementService, + private readonly pluginTranslationService: PluginTranslationService, + private readonly translateService: TranslateService + ) {} + + public ngOnInit(): void { + this.loadRows(this.pluginConfigurations); + } + + /** + * The mapping of every source plugin configuration to the selected target, or null when no target + * could be selected. + */ + public getMappings(): Record { + return this.rows$.value.reduce((mappings, row) => { + const control = this.form.get(row.sourcePluginConfigurationId); + return { + ...mappings, + [row.sourcePluginConfigurationId]: + row.status === 'available' ? (control?.value ?? null) : null, + }; + }, {}); + } + + public trackBySourceId(_index: number, row: PluginMappingRow): string { + return row.sourcePluginConfigurationId; + } + + /** + * Works around a carbon-components-angular bug where clearing a single-select + * cds-combo-box with itemValueKey set writes `[]` to the FormControl instead of `null`. + */ + public onClear(sourceId: string): void { + this.form.get(sourceId)?.setValue(null); + } + + private loadRows(pluginConfigurations: PluginConfigurationPreview[]): void { + const uniqueById = new Map(); + pluginConfigurations.forEach(configuration => { + if (!uniqueById.has(configuration.pluginConfigurationId)) { + uniqueById.set(configuration.pluginConfigurationId, configuration); + } + }); + const allConfigurations = Array.from(uniqueById.values()); + + // A configuration without a plugin definition key can only be used when its id exists here + this.hasUnidentifiablePlugins$.next( + allConfigurations.some( + configuration => + configuration.pluginDefinitionKey === null && !configuration.existsInTargetEnvironment + ) + ); + + const mappableConfigurations = allConfigurations.filter( + configuration => configuration.pluginDefinitionKey !== null + ); + if (mappableConfigurations.length === 0) { + this.rows$.next([]); + return; + } + + this.pluginManagementService + .getPluginDefinitions() + .pipe(take(1)) + .subscribe(definitions => { + this.loadConfigurations(mappableConfigurations, new Set(definitions.map(({key}) => key))); + }); + } + + private loadConfigurations( + configurations: PluginConfigurationPreview[], + installedKeys: Set + ): void { + const installableKeys = [ + ...new Set(configurations.map(({pluginDefinitionKey}) => pluginDefinitionKey)), + ].filter(key => !!key && installedKeys.has(key)); + + if (installableKeys.length === 0) { + this.buildRows(configurations, new Map(), installedKeys); + return; + } + + const requests = installableKeys.reduce( + (accumulator, key) => ({ + ...accumulator, + [key]: this.pluginManagementService + .getPluginConfigurationsByPluginDefinitionKey(key) + .pipe(take(1)), + }), + {} as Record> + ); + + forkJoin(requests) + .pipe(take(1)) + .subscribe(results => { + this.buildRows(configurations, new Map(Object.entries(results)), installedKeys); + }); + } + + private buildRows( + configurations: PluginConfigurationPreview[], + configurationsByKey: Map, + installedKeys: Set + ): void { + this.clearForm(); + + const rows: PluginMappingRow[] = configurations.map(configuration => { + const key = configuration.pluginDefinitionKey; + const available = (key && configurationsByKey.get(key)) || []; + const defaultSelectionId = configuration.existsInTargetEnvironment + ? configuration.pluginConfigurationId + : null; + + let status: PluginMappingStatus; + if (!key || !installedKeys.has(key)) { + status = 'not-installed'; + } else if (available.length === 0) { + status = 'no-configurations'; + } else { + status = 'available'; + } + + if (status === 'available') { + this.form.addControl( + configuration.pluginConfigurationId, + this.formBuilder.control(defaultSelectionId) + ); + } + + return { + pluginDefinitionKey: key, + pluginDefinitionTitle: this.getPluginTitle(key), + sourcePluginConfigurationId: configuration.pluginConfigurationId, + existsInTargetEnvironment: configuration.existsInTargetEnvironment, + listItems: available.map(({title, id}) => ({ + content: title, + id, + selected: id === defaultSelectionId, + })), + status, + }; + }); + + this.rows$.next(rows); + } + + private clearForm(): void { + Object.keys(this.form.controls).forEach(key => this.form.removeControl(key)); + } + + private getPluginTitle(pluginDefinitionKey: string | null): string { + if (!pluginDefinitionKey) { + return this.translateService.instant('pluginConfigurationMapping.unknownPlugin'); + } + + const translated = this.pluginTranslationService.instant('title', pluginDefinitionKey); + // The translation service falls back to ".title" when there is no translation + return translated === `${pluginDefinitionKey}.title` ? pluginDefinitionKey : translated; + } +} diff --git a/frontend/projects/valtimo/plugin/src/public-api.ts b/frontend/projects/valtimo/plugin/src/public-api.ts index 85b0d45056..8f3b3b80e3 100644 --- a/frontend/projects/valtimo/plugin/src/public-api.ts +++ b/frontend/projects/valtimo/plugin/src/public-api.ts @@ -25,6 +25,8 @@ export * from './lib/constants'; /* plugin configuration container */ export * from './lib/components/plugin-configuration-container/plugin-configuration-container.component'; export * from './lib/components/plugin-configuration-container/plugin-configuration-container.module'; +/* plugin configuration mapping */ +export * from './lib/components/plugin-configuration-mapping/plugin-configuration-mapping.component'; /* open-zaak plugin */ export * from './lib/plugins/open-zaak/open-zaak-plugin.module'; export * from './lib/plugins/open-zaak/components/open-zaak-configuration/open-zaak-configuration.component'; diff --git a/frontend/projects/valtimo/process-management/src/lib/components/process-management-builder/process-management-builder.component.html b/frontend/projects/valtimo/process-management/src/lib/components/process-management-builder/process-management-builder.component.html index 84df2e26ad..9454ed38ff 100644 --- a/frontend/projects/valtimo/process-management/src/lib/components/process-management-builder/process-management-builder.component.html +++ b/frontend/projects/valtimo/process-management/src/lib/components/process-management-builder/process-management-builder.component.html @@ -236,6 +236,14 @@ (selected)="export(actionsObs.isReadOnlyProcess)" >{{ 'interface.export' | translate }} + + {{ 'processManagement.exportWithProcessLinks' | translate }} +